mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix: gate chat advisor and virtual desktop behind experiments, delete experiments page (#26809)
This commit is contained in:
Generated
+11
-3
@@ -19630,12 +19630,16 @@ const docTemplate = `{
|
||||
"nats_pubsub",
|
||||
"minimum-implicit-member",
|
||||
"ai-gateway-cost-control",
|
||||
"agent-app-tabs"
|
||||
"agent-app-tabs",
|
||||
"chat-advisor",
|
||||
"chat-virtual-desktop"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.",
|
||||
"ExperimentAgentAppTabs": "Enables workspace-app and port preview tabs in the Coder Agents right panel.",
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentChatAdvisor": "Enables the advisor tool for root agent chats.",
|
||||
"ExperimentChatVirtualDesktop": "Enables virtual desktop and computer use provider for agents.",
|
||||
"ExperimentExample": "This isn't used for anything.",
|
||||
"ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.",
|
||||
"ExperimentMinimumImplicitMember": "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.",
|
||||
@@ -19656,7 +19660,9 @@ const docTemplate = `{
|
||||
"Enables embedded NATS pubsub.",
|
||||
"Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.",
|
||||
"Enables AI Gateway cost control functionality.",
|
||||
"Enables workspace-app and port preview tabs in the Coder Agents right panel."
|
||||
"Enables workspace-app and port preview tabs in the Coder Agents right panel.",
|
||||
"Enables the advisor tool for root agent chats.",
|
||||
"Enables virtual desktop and computer use provider for agents."
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ExperimentExample",
|
||||
@@ -19669,7 +19675,9 @@ const docTemplate = `{
|
||||
"ExperimentNATSPubsub",
|
||||
"ExperimentMinimumImplicitMember",
|
||||
"ExperimentAIGatewayCostControl",
|
||||
"ExperimentAgentAppTabs"
|
||||
"ExperimentAgentAppTabs",
|
||||
"ExperimentChatAdvisor",
|
||||
"ExperimentChatVirtualDesktop"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAPIKeyScopes": {
|
||||
|
||||
Generated
+11
-3
@@ -17823,12 +17823,16 @@
|
||||
"nats_pubsub",
|
||||
"minimum-implicit-member",
|
||||
"ai-gateway-cost-control",
|
||||
"agent-app-tabs"
|
||||
"agent-app-tabs",
|
||||
"chat-advisor",
|
||||
"chat-virtual-desktop"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.",
|
||||
"ExperimentAgentAppTabs": "Enables workspace-app and port preview tabs in the Coder Agents right panel.",
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentChatAdvisor": "Enables the advisor tool for root agent chats.",
|
||||
"ExperimentChatVirtualDesktop": "Enables virtual desktop and computer use provider for agents.",
|
||||
"ExperimentExample": "This isn't used for anything.",
|
||||
"ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.",
|
||||
"ExperimentMinimumImplicitMember": "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.",
|
||||
@@ -17849,7 +17853,9 @@
|
||||
"Enables embedded NATS pubsub.",
|
||||
"Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.",
|
||||
"Enables AI Gateway cost control functionality.",
|
||||
"Enables workspace-app and port preview tabs in the Coder Agents right panel."
|
||||
"Enables workspace-app and port preview tabs in the Coder Agents right panel.",
|
||||
"Enables the advisor tool for root agent chats.",
|
||||
"Enables virtual desktop and computer use provider for agents."
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ExperimentExample",
|
||||
@@ -17862,7 +17868,9 @@
|
||||
"ExperimentNATSPubsub",
|
||||
"ExperimentMinimumImplicitMember",
|
||||
"ExperimentAIGatewayCostControl",
|
||||
"ExperimentAgentAppTabs"
|
||||
"ExperimentAgentAppTabs",
|
||||
"ExperimentChatAdvisor",
|
||||
"ExperimentChatVirtualDesktop"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAPIKeyScopes": {
|
||||
|
||||
+11
-6
@@ -840,6 +840,7 @@ func New(options *Options) *API {
|
||||
AIBridgeTransportFactory: &api.AIBridgeTransportFactory,
|
||||
AIGatewayRoutingEnabled: chatAIGatewayRoutingEnabled,
|
||||
AlwaysEnableDebugLogs: options.DeploymentValues.AI.Chat.DebugLoggingEnabled.Value(),
|
||||
Experiments: experiments,
|
||||
AgentConn: api.agentProvider.AgentConn,
|
||||
AgentInactiveDisconnectTimeout: api.AgentInactiveDisconnectTimeout,
|
||||
InstructionLookupTimeout: options.ChatdInstructionLookupTimeout,
|
||||
@@ -1279,16 +1280,20 @@ func New(options *Options) *API {
|
||||
r.Put("/personal-model-overrides", api.putChatPersonalModelOverridesAdminSettings)
|
||||
r.Get("/user-personal-model-overrides", api.getUserChatPersonalModelOverrides)
|
||||
r.Put("/user-personal-model-overrides/{context}", api.putUserChatPersonalModelOverride)
|
||||
r.Get("/desktop-enabled", api.getChatDesktopEnabled)
|
||||
r.Put("/desktop-enabled", api.putChatDesktopEnabled)
|
||||
r.Get("/computer-use-provider", api.getChatComputerUseProvider)
|
||||
r.Put("/computer-use-provider", api.putChatComputerUseProvider)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentChatVirtualDesktop))
|
||||
r.Get("/computer-use-provider", api.getChatComputerUseProvider)
|
||||
r.Put("/computer-use-provider", api.putChatComputerUseProvider)
|
||||
})
|
||||
r.Get("/debug-logging", api.getChatDebugLogging)
|
||||
r.Put("/debug-logging", api.putChatDebugLogging)
|
||||
r.Get("/user-debug-logging", api.getUserChatDebugLogging)
|
||||
r.Put("/user-debug-logging", api.putUserChatDebugLogging)
|
||||
r.Get("/advisor", api.getChatAdvisorConfig)
|
||||
r.Put("/advisor", api.putChatAdvisorConfig)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentChatAdvisor))
|
||||
r.Get("/advisor", api.getChatAdvisorConfig)
|
||||
r.Put("/advisor", api.putChatAdvisorConfig)
|
||||
})
|
||||
r.Get("/user-prompt", api.getUserChatCustomPrompt)
|
||||
r.Put("/user-prompt", api.putUserChatCustomPrompt)
|
||||
r.Get("/user-compaction-thresholds", api.getUserChatCompactionThresholds)
|
||||
|
||||
+1
-43
@@ -5176,49 +5176,6 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
|
||||
func (api *API) getChatDesktopEnabled(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
enabled, err := api.Database.GetChatDesktopEnabled(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching desktop setting.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatDesktopEnabledResponse{
|
||||
EnableDesktop: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
func (api *API) putChatDesktopEnabled(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.UpdateChatDesktopEnabledRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := api.Database.UpsertChatDesktopEnabled(ctx, req.EnableDesktop); httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
} else if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error updating desktop setting.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
|
||||
@@ -5429,6 +5386,7 @@ func (api *API) getChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
resp.MaxUsesPerRun = max(resp.MaxUsesPerRun, 0)
|
||||
resp.MaxOutputTokens = max(resp.MaxOutputTokens, 0)
|
||||
resp.Enabled = api.Experiments.Enabled(codersdk.ExperimentChatAdvisor)
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
+21
-111
@@ -69,6 +69,14 @@ func newChatTestOptions(
|
||||
) *coderdtest.Options {
|
||||
t.Helper()
|
||||
|
||||
// Enable experiment-gated chat endpoints in tests.
|
||||
if len(values.Experiments) == 0 {
|
||||
values.Experiments = serpent.StringArray{
|
||||
string(codersdk.ExperimentChatAdvisor),
|
||||
string(codersdk.ExperimentChatVirtualDesktop),
|
||||
}
|
||||
}
|
||||
|
||||
opts := &coderdtest.Options{
|
||||
DeploymentValues: values,
|
||||
}
|
||||
@@ -12270,110 +12278,6 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatDesktopEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ReturnsFalseWhenUnset", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
adminClient := newChatClient(t)
|
||||
coderdtest.CreateFirstUser(t, adminClient.Client)
|
||||
|
||||
resp, err := adminClient.GetChatDesktopEnabled(ctx)
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.EnableDesktop)
|
||||
})
|
||||
|
||||
t.Run("AdminCanSetTrue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
adminClient := newChatClient(t)
|
||||
coderdtest.CreateFirstUser(t, adminClient.Client)
|
||||
|
||||
err := adminClient.UpdateChatDesktopEnabled(ctx, codersdk.UpdateChatDesktopEnabledRequest{
|
||||
EnableDesktop: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := adminClient.GetChatDesktopEnabled(ctx)
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.EnableDesktop)
|
||||
})
|
||||
|
||||
t.Run("AdminCanSetFalse", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
adminClient := newChatClient(t)
|
||||
coderdtest.CreateFirstUser(t, adminClient.Client)
|
||||
|
||||
// Set true first, then set false.
|
||||
err := adminClient.UpdateChatDesktopEnabled(ctx, codersdk.UpdateChatDesktopEnabledRequest{
|
||||
EnableDesktop: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = adminClient.UpdateChatDesktopEnabled(ctx, codersdk.UpdateChatDesktopEnabledRequest{
|
||||
EnableDesktop: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := adminClient.GetChatDesktopEnabled(ctx)
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.EnableDesktop)
|
||||
})
|
||||
|
||||
t.Run("NonAdminCanRead", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
adminClient := newChatClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
|
||||
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
|
||||
err := adminClient.UpdateChatDesktopEnabled(ctx, codersdk.UpdateChatDesktopEnabledRequest{
|
||||
EnableDesktop: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := memberClient.GetChatDesktopEnabled(ctx)
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.EnableDesktop)
|
||||
})
|
||||
|
||||
t.Run("NonAdminWriteFails", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
adminClient := newChatClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
|
||||
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
|
||||
err := memberClient.UpdateChatDesktopEnabled(ctx, codersdk.UpdateChatDesktopEnabledRequest{
|
||||
EnableDesktop: true,
|
||||
})
|
||||
requireSDKError(t, err, http.StatusForbidden)
|
||||
})
|
||||
|
||||
t.Run("UnauthenticatedFails", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
adminClient := newChatClient(t)
|
||||
coderdtest.CreateFirstUser(t, adminClient.Client)
|
||||
|
||||
anonClient := codersdk.NewExperimentalClient(codersdk.New(adminClient.URL))
|
||||
_, err := anonClient.GetChatDesktopEnabled(ctx)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatComputerUseProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -13000,7 +12904,12 @@ func TestChatAdvisorConfig_GetDefault(t *testing.T) {
|
||||
|
||||
resp, err := adminClient.GetChatAdvisorConfig(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.AdvisorConfig{}, resp)
|
||||
// Enabled reflects the experiment state, not the DB value. The test
|
||||
// deployment enables chat-advisor, so Enabled is true.
|
||||
require.True(t, resp.Enabled)
|
||||
require.Equal(t, 0, resp.MaxUsesPerRun)
|
||||
require.Equal(t, int64(0), resp.MaxOutputTokens)
|
||||
require.Equal(t, uuid.Nil, resp.ModelConfigID)
|
||||
}
|
||||
|
||||
func TestChatAdvisorConfig_Update(t *testing.T) {
|
||||
@@ -13189,11 +13098,11 @@ func TestChatAdvisorConfig_OverwriteClearsPreviousValues(t *testing.T) {
|
||||
require.Equal(t, sparse, resp)
|
||||
}
|
||||
|
||||
// TestChatAdvisorConfig_CanBeDisabledAfterEnabled pins the feature
|
||||
// gate's "off" path. The downstream runtime gates the advisor tool and
|
||||
// prompt guidance on Enabled, so a regression that silently drops or
|
||||
// ignores Enabled: false on PUT would leave the feature stuck on.
|
||||
func TestChatAdvisorConfig_CanBeDisabledAfterEnabled(t *testing.T) {
|
||||
// TestChatAdvisorConfig_EnabledReflectsExperiment pins that the Enabled
|
||||
// field in the GET response reflects the experiment state, not the DB-stored
|
||||
// value. Setting Enabled: false via PUT stores false in the DB, but the GET
|
||||
// handler overrides it with the experiment check.
|
||||
func TestChatAdvisorConfig_EnabledReflectsExperiment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
@@ -13217,7 +13126,8 @@ func TestChatAdvisorConfig_CanBeDisabledAfterEnabled(t *testing.T) {
|
||||
|
||||
disabledResp, err := adminClient.GetChatAdvisorConfig(ctx)
|
||||
require.NoError(t, err)
|
||||
require.False(t, disabledResp.Enabled)
|
||||
// Enabled reflects the experiment state (on), not the DB-stored false.
|
||||
require.True(t, disabledResp.Enabled)
|
||||
}
|
||||
|
||||
func TestChatAdvisorConfig_ClampsNegativeStoredValues(t *testing.T) {
|
||||
|
||||
@@ -190,6 +190,7 @@ type Server struct {
|
||||
|
||||
aibridgeTransportFactory *atomic.Pointer[aibridge.TransportFactory]
|
||||
aiGatewayRoutingEnabled bool
|
||||
experiments codersdk.Experiments
|
||||
|
||||
// Configuration
|
||||
pendingChatAcquireInterval time.Duration
|
||||
@@ -3166,6 +3167,7 @@ type Config struct {
|
||||
Clock quartz.Clock
|
||||
AIBridgeTransportFactory *atomic.Pointer[aibridge.TransportFactory]
|
||||
AIGatewayRoutingEnabled bool
|
||||
Experiments codersdk.Experiments
|
||||
|
||||
PrometheusRegistry prometheus.Registerer
|
||||
|
||||
@@ -3262,6 +3264,7 @@ func New(ps pubsub.Pubsub, cfg Config) *Server {
|
||||
},
|
||||
aibridgeTransportFactory: cfg.AIBridgeTransportFactory,
|
||||
aiGatewayRoutingEnabled: cfg.AIGatewayRoutingEnabled,
|
||||
experiments: cfg.Experiments,
|
||||
pendingChatAcquireInterval: pendingChatAcquireInterval,
|
||||
maxChatsPerAcquire: maxChatsPerAcquire,
|
||||
inFlightChatStaleAfter: inFlightChatStaleAfter,
|
||||
|
||||
@@ -5240,6 +5240,7 @@ func newTestServer(
|
||||
Database: db,
|
||||
ReplicaID: replicaID,
|
||||
PendingChatAcquireInterval: testutil.WaitLong,
|
||||
Experiments: codersdk.ExperimentsKnown,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, server.Close())
|
||||
@@ -8135,6 +8136,7 @@ func newActiveTestServer(
|
||||
ReplicaID: uuid.New(),
|
||||
PendingChatAcquireInterval: 10 * time.Millisecond,
|
||||
InFlightChatStaleAfter: testutil.WaitSuperLong,
|
||||
Experiments: codersdk.ExperimentsKnown,
|
||||
}
|
||||
for _, o := range overrides {
|
||||
o(&cfg)
|
||||
@@ -9319,9 +9321,6 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) {
|
||||
BaseUrl: anthropicSrv.URL,
|
||||
})
|
||||
|
||||
err := db.UpsertChatDesktopEnabled(ctx, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Build workspace + agent records so getWorkspaceConn can
|
||||
// resolve the agent for the computer use child.
|
||||
ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID)
|
||||
@@ -9514,6 +9513,7 @@ func TestInterruptChatPersistsPartialResponse(t *testing.T) {
|
||||
ReplicaID: uuid.New(),
|
||||
PendingChatAcquireInterval: 10 * time.Millisecond,
|
||||
InFlightChatStaleAfter: testutil.WaitSuperLong,
|
||||
Experiments: codersdk.ExperimentsKnown,
|
||||
})
|
||||
server.Start()
|
||||
t.Cleanup(func() {
|
||||
@@ -11602,51 +11602,61 @@ func TestAcquireChatsSkipsArchivedPendingChat(t *testing.T) {
|
||||
require.Equal(t, activeChat.ID, acquired[0].ID)
|
||||
}
|
||||
|
||||
func TestAdvisorGating_Disabled(t *testing.T) {
|
||||
// TestAdvisorGating_ExperimentDisabled verifies that the advisor tool is
|
||||
// not attached when the chat-advisor experiment is absent from the
|
||||
// experiments list, even if the DB-stored advisor config has Enabled=true.
|
||||
func TestAdvisorGating_ExperimentDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
var toolsMu sync.Mutex
|
||||
var capturedTools []string
|
||||
var capturedMessages []chattest.OpenAIMessage
|
||||
var streamedCallCount atomic.Int32
|
||||
var streamedCallsMu sync.Mutex
|
||||
var firstCallTools []string
|
||||
|
||||
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
if !req.Stream {
|
||||
return chattest.OpenAINonStreamingResponse("title")
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(req.Tools))
|
||||
for _, tool := range req.Tools {
|
||||
names = append(names, tool.Function.Name)
|
||||
if streamedCallCount.Add(1) == 1 {
|
||||
names := make([]string, 0, len(req.Tools))
|
||||
for _, tool := range req.Tools {
|
||||
names = append(names, tool.Function.Name)
|
||||
}
|
||||
streamedCallsMu.Lock()
|
||||
firstCallTools = names
|
||||
streamedCallsMu.Unlock()
|
||||
}
|
||||
toolsMu.Lock()
|
||||
capturedTools = names
|
||||
capturedMessages = append([]chattest.OpenAIMessage(nil), req.Messages...)
|
||||
toolsMu.Unlock()
|
||||
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAITextChunks("advisor is not available")...,
|
||||
chattest.OpenAITextChunks("done")...,
|
||||
)
|
||||
})
|
||||
|
||||
user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL)
|
||||
seedAdvisorConfig(ctx, t, db, codersdk.AdvisorConfig{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
MaxUsesPerRun: 3,
|
||||
MaxOutputTokens: 16384,
|
||||
})
|
||||
server := newActiveTestServer(t, db, ps)
|
||||
experiments := slices.DeleteFunc(
|
||||
slices.Clone(codersdk.ExperimentsKnown),
|
||||
func(e codersdk.Experiment) bool { return e == codersdk.ExperimentChatAdvisor },
|
||||
)
|
||||
server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
|
||||
cfg.Experiments = experiments
|
||||
})
|
||||
|
||||
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
APIKeyID: testAPIKeyID(t, db, user.ID),
|
||||
Title: "advisor-disabled",
|
||||
Title: "advisor-experiment-disabled",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("hello"),
|
||||
codersdk.ChatMessageText("help me plan this"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -11656,22 +11666,20 @@ func TestAdvisorGating_Disabled(t *testing.T) {
|
||||
if getErr != nil {
|
||||
return false
|
||||
}
|
||||
return got.Status == database.ChatStatusWaiting ||
|
||||
got.Status == database.ChatStatusError
|
||||
if got.Status != database.ChatStatusWaiting &&
|
||||
got.Status != database.ChatStatusError {
|
||||
return false
|
||||
}
|
||||
return streamedCallCount.Load() >= 1
|
||||
}, testutil.WaitLong, testutil.IntervalFast)
|
||||
|
||||
toolsMu.Lock()
|
||||
tools := append([]string(nil), capturedTools...)
|
||||
messages := append([]chattest.OpenAIMessage(nil), capturedMessages...)
|
||||
toolsMu.Unlock()
|
||||
streamedCallsMu.Lock()
|
||||
tools := append([]string(nil), firstCallTools...)
|
||||
streamedCallsMu.Unlock()
|
||||
|
||||
require.NotEmpty(t, messages, "expected a streamed LLM request")
|
||||
require.NotEmpty(t, tools, "expected at least one streamed LLM request")
|
||||
require.NotContains(t, tools, "advisor",
|
||||
"advisor tool should not be registered when disabled")
|
||||
for _, msg := range messages {
|
||||
require.NotContains(t, msg.Content, chatadvisor.ParentGuidanceBlock,
|
||||
"advisor guidance should not be injected when disabled")
|
||||
}
|
||||
"advisor tool must not be registered when the chat-advisor experiment is absent")
|
||||
}
|
||||
|
||||
func TestAdvisorGating_RootChat(t *testing.T) {
|
||||
|
||||
@@ -118,6 +118,8 @@ func (server *Server) prepareGeneration(
|
||||
|
||||
planModeInstructions := server.loadPlanModeInstructions(ctx, currentPlanMode, logger)
|
||||
advisorCfg := server.loadAdvisorConfig(ctx, logger)
|
||||
// Force Enabled from the experiment; the stored DB value is ignored.
|
||||
advisorCfg.Enabled = server.experiments.Enabled(codersdk.ExperimentChatAdvisor)
|
||||
|
||||
var advisorRuntime *chatadvisor.Runtime
|
||||
if advisorCfg.Enabled && isRootChat && !isPlanModeTurn && !isExploreSubagent {
|
||||
|
||||
@@ -114,14 +114,6 @@ type listAgentsArgs struct {
|
||||
Offset *int `json:"offset,omitempty"`
|
||||
}
|
||||
|
||||
func (p *Server) isDesktopEnabled(ctx context.Context) bool {
|
||||
enabled, err := p.db.GetChatDesktopEnabled(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
func subagentModelOverrideLogLabel(
|
||||
overrideContext codersdk.ChatModelOverrideContext,
|
||||
) string {
|
||||
|
||||
@@ -114,8 +114,8 @@ func allSubagentDefinitions() []subagentDefinition {
|
||||
if currentChat.PlanMode.Valid && currentChat.PlanMode.ChatPlanMode == database.ChatPlanModePlan {
|
||||
return `type "computer_use" is unavailable in plan mode`
|
||||
}
|
||||
if !p.isDesktopEnabled(ctx) {
|
||||
return `type "computer_use" is unavailable because desktop access is not enabled`
|
||||
if !p.experiments.Enabled(codersdk.ExperimentChatVirtualDesktop) {
|
||||
return `type "computer_use" is unavailable because the chat-virtual-desktop experiment is not enabled`
|
||||
}
|
||||
_, _, _, err := p.computerUseProviderAndModelFromConfig(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -96,7 +96,6 @@ func TestSpawnComputerUseAgentInheritsPinnedContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -73,6 +74,7 @@ type internalTestServerConfig struct {
|
||||
logger slog.Logger
|
||||
clock quartz.Clock
|
||||
startWorker bool
|
||||
experiments codersdk.Experiments
|
||||
}
|
||||
|
||||
type internalTestServerOpt func(*internalTestServerConfig)
|
||||
@@ -95,6 +97,19 @@ func withInternalTestServerWorker() internalTestServerOpt {
|
||||
}
|
||||
}
|
||||
|
||||
func withInternalTestServerExperiments(experiments codersdk.Experiments) internalTestServerOpt {
|
||||
return func(cfg *internalTestServerConfig) {
|
||||
cfg.experiments = experiments
|
||||
}
|
||||
}
|
||||
|
||||
func experimentsOrDefault(experiments codersdk.Experiments) codersdk.Experiments {
|
||||
if experiments == nil {
|
||||
return codersdk.ExperimentsKnown
|
||||
}
|
||||
return experiments
|
||||
}
|
||||
|
||||
// newInternalTestServer creates a passive Server for internal tests with
|
||||
// custom provider API keys. Pass withInternalTestServerWorker to start the
|
||||
// background chat worker for tests that need real execution.
|
||||
@@ -123,6 +138,7 @@ func newInternalTestServer(
|
||||
// does not interfere with test assertions.
|
||||
PendingChatAcquireInterval: testutil.WaitLong,
|
||||
ProviderAPIKeys: keys,
|
||||
Experiments: experimentsOrDefault(cfg.experiments),
|
||||
})
|
||||
if cfg.startWorker {
|
||||
server.Start()
|
||||
@@ -2150,7 +2166,6 @@ func TestSpawnAgent_DescriptionListsAllAvailableTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
|
||||
Anthropic: "test-anthropic-key",
|
||||
})
|
||||
@@ -2198,7 +2213,6 @@ func TestSpawnAgent_DescriptionIncludesComputerUseWithMissingProviderKey(t *test
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
@@ -2220,7 +2234,6 @@ func TestSpawnAgent_PlanModeDescriptionOmitsComputerUse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
|
||||
Anthropic: "test-anthropic-key",
|
||||
})
|
||||
@@ -2261,7 +2274,6 @@ func TestSpawnAgent_PlanModeRejectsComputerUse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
|
||||
Anthropic: "test-anthropic-key",
|
||||
})
|
||||
@@ -2312,7 +2324,6 @@ func TestSpawnAgent_InvalidTypeAndCredentialErrorAreDistinct(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
@@ -2353,7 +2364,6 @@ func TestSpawnAgent_ComputerUseAvailabilityUsesConfiguredProvider(t *testing.T)
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := chatdTestContext(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(ctx, true))
|
||||
require.NoError(t, db.UpsertChatComputerUseProvider(
|
||||
ctx,
|
||||
chattool.ComputerUseProviderOpenAI,
|
||||
@@ -2374,7 +2384,6 @@ func TestSpawnAgent_ComputerUseRejectsMissingConfiguredProvider(t *testing.T) {
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := chatdTestContext(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(ctx, true))
|
||||
require.NoError(t, db.UpsertChatComputerUseProvider(
|
||||
ctx,
|
||||
chattool.ComputerUseProviderOpenAI,
|
||||
@@ -2434,7 +2443,6 @@ func TestSpawnAgent_ComputerUseRejectsInvalidConfiguredProviderWithStableReason(
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := chatdTestContext(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(ctx, true))
|
||||
require.NoError(t, db.UpsertChatComputerUseProvider(ctx, "bogus"))
|
||||
logSink := &subagentTestLogSink{}
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink)
|
||||
@@ -2463,9 +2471,13 @@ func TestSpawnAgent_ComputerUseRejectsDesktopDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
experiments := slices.DeleteFunc(
|
||||
slices.Clone(codersdk.ExperimentsKnown),
|
||||
func(e codersdk.Experiment) bool { return e == codersdk.ExperimentChatVirtualDesktop },
|
||||
)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
|
||||
Anthropic: "test-anthropic-key",
|
||||
})
|
||||
}, withInternalTestServerExperiments(experiments))
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, org, model := seedInternalChatDeps(t, db)
|
||||
@@ -2478,14 +2490,13 @@ func TestSpawnAgent_ComputerUseRejectsDesktopDisabled(t *testing.T) {
|
||||
Prompt: "open the browser",
|
||||
})
|
||||
require.True(t, resp.IsError)
|
||||
require.Contains(t, resp.Content, `type "computer_use" is unavailable because desktop access is not enabled`)
|
||||
require.Contains(t, resp.Content, `type "computer_use" is unavailable because the chat-virtual-desktop experiment is not enabled`)
|
||||
}
|
||||
|
||||
func TestSpawnAgent_BlankTypeReturnsValidOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
|
||||
Anthropic: "test-anthropic-key",
|
||||
})
|
||||
@@ -2527,7 +2538,6 @@ func TestSpawnAgent_NotAvailableForChildChats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
|
||||
Anthropic: "test-anthropic-key",
|
||||
})
|
||||
@@ -2609,9 +2619,6 @@ func TestSubagentLifecycleToolsIncludePersistedSubagentTypeAcrossVariants(t *tes
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
if tt.variant == subagentTypeComputerUse {
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
}
|
||||
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
@@ -2750,7 +2757,6 @@ func TestSpawnAgent_ComputerUseUsesComputerUseModelNotParent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
@@ -2810,7 +2816,6 @@ func TestSpawnAgent_ComputerUseInheritsMCPServerIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
|
||||
+3
-39
@@ -876,23 +876,14 @@ type UpdateUserChatCompactionThresholdRequest struct {
|
||||
ThresholdPercent int32 `json:"threshold_percent" validate:"min=0,max=100"`
|
||||
}
|
||||
|
||||
// ChatDesktopEnabledResponse is the response for getting the desktop setting.
|
||||
type ChatDesktopEnabledResponse struct {
|
||||
EnableDesktop bool `json:"enable_desktop"`
|
||||
}
|
||||
|
||||
// UpdateChatDesktopEnabledRequest is the request to update the desktop setting.
|
||||
type UpdateChatDesktopEnabledRequest struct {
|
||||
EnableDesktop bool `json:"enable_desktop"`
|
||||
}
|
||||
|
||||
// AdvisorConfig is the deployment-wide runtime configuration for the
|
||||
// experimental chat advisor.
|
||||
//
|
||||
// EXPERIMENTAL: this type is experimental and is subject to change.
|
||||
type AdvisorConfig struct {
|
||||
// Enabled toggles the advisor runtime. When false, advisor is not
|
||||
// attached to new chats.
|
||||
// Enabled reflects whether the chat-advisor experiment is active.
|
||||
// The experiment flag is the sole gate; this field is read-only and
|
||||
// always matches the experiment state regardless of the stored DB value.
|
||||
Enabled bool `json:"enabled"`
|
||||
// MaxUsesPerRun caps how many times the advisor can be invoked per
|
||||
// chat run. 0 means unlimited.
|
||||
@@ -2649,33 +2640,6 @@ func (c *ExperimentalClient) GetUserChatCustomPrompt(ctx context.Context) (UserC
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// GetChatDesktopEnabled returns the deployment-wide desktop setting.
|
||||
func (c *ExperimentalClient) GetChatDesktopEnabled(ctx context.Context) (ChatDesktopEnabledResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/desktop-enabled", nil)
|
||||
if err != nil {
|
||||
return ChatDesktopEnabledResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ChatDesktopEnabledResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp ChatDesktopEnabledResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// UpdateChatDesktopEnabled updates the deployment-wide desktop setting.
|
||||
func (c *ExperimentalClient) UpdateChatDesktopEnabled(ctx context.Context, req UpdateChatDesktopEnabledRequest) error {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/desktop-enabled", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChatAdvisorConfig returns the deployment-wide advisor configuration.
|
||||
func (c *ExperimentalClient) GetChatAdvisorConfig(ctx context.Context) (AdvisorConfig, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/advisor", nil)
|
||||
|
||||
@@ -5198,6 +5198,8 @@ const (
|
||||
ExperimentMinimumImplicitMember Experiment = "minimum-implicit-member" // Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.
|
||||
ExperimentAIGatewayCostControl Experiment = "ai-gateway-cost-control" // Enables AI Gateway cost control functionality.
|
||||
ExperimentAgentAppTabs Experiment = "agent-app-tabs" // Enables workspace-app and port preview tabs in the Coder Agents right panel.
|
||||
ExperimentChatAdvisor Experiment = "chat-advisor" // Enables the advisor tool for root agent chats.
|
||||
ExperimentChatVirtualDesktop Experiment = "chat-virtual-desktop" // Enables virtual desktop and computer use provider for agents.
|
||||
)
|
||||
|
||||
func (e Experiment) DisplayName() string {
|
||||
@@ -5224,6 +5226,10 @@ func (e Experiment) DisplayName() string {
|
||||
return "AI Gateway Cost Control"
|
||||
case ExperimentAgentAppTabs:
|
||||
return "Coder Agents App and Port Tabs"
|
||||
case ExperimentChatAdvisor:
|
||||
return "Chat Advisor"
|
||||
case ExperimentChatVirtualDesktop:
|
||||
return "Chat Virtual Desktop"
|
||||
default:
|
||||
// Split on hyphen and convert to title case
|
||||
// e.g. "mcp-server-http" -> "Mcp Server Http"
|
||||
@@ -5245,6 +5251,8 @@ var ExperimentsKnown = Experiments{
|
||||
ExperimentMinimumImplicitMember,
|
||||
ExperimentAIGatewayCostControl,
|
||||
ExperimentAgentAppTabs,
|
||||
ExperimentChatAdvisor,
|
||||
ExperimentChatVirtualDesktop,
|
||||
}
|
||||
|
||||
// ExperimentsSafe should include all experiments that are safe for
|
||||
|
||||
@@ -173,14 +173,14 @@ provider-native, and computer-use tools are not available.
|
||||
These tools manage sub-agents — child chats that work on independent tasks in
|
||||
parallel.
|
||||
|
||||
| Tool | What it does |
|
||||
|---------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `spawn_agent` (`type=general` or `explore`) | Delegates a task to a sub-agent with its own context window. |
|
||||
| `wait_agent` | Waits for a sub-agent to finish and collects its result. |
|
||||
| `message_agent` | Sends a follow-up message to a running sub-agent. |
|
||||
| `interrupt_agent` | Halts a sub-agent's current turn; it transitions to waiting or running if there are queued messages. |
|
||||
| `spawn_agent` (`type=computer_use`) | Spawns a sub-agent with desktop interaction capabilities (screenshot, mouse, keyboard). Requires an administrator-configured computer-use provider (Anthropic or OpenAI) and the [virtual desktop experiment](./platform-controls/experiments.md#virtual-desktop) to be enabled. |
|
||||
| `list_agents` | Lists spawned child agents, most recently active first. |
|
||||
| Tool | What it does |
|
||||
|---------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `spawn_agent` (`type=general` or `explore`) | Delegates a task to a sub-agent with its own context window. |
|
||||
| `wait_agent` | Waits for a sub-agent to finish and collects its result. |
|
||||
| `message_agent` | Sends a follow-up message to a running sub-agent. |
|
||||
| `interrupt_agent` | Halts a sub-agent's current turn; it transitions to waiting or running if there are queued messages. |
|
||||
| `spawn_agent` (`type=computer_use`) | Spawns a sub-agent with desktop interaction capabilities (screenshot, mouse, keyboard). Requires an administrator-configured computer-use provider (Anthropic or OpenAI) and the [virtual desktop experiment](./platform-controls/virtual-desktop.md) to be enabled. |
|
||||
| `list_agents` | Lists spawned child agents, most recently active first. |
|
||||
|
||||
### Provider tools
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Advisor
|
||||
|
||||
> [!NOTE]
|
||||
> This feature is experimental. Pin a release before broad rollout and review
|
||||
> the release notes before upgrading.
|
||||
|
||||
## Enable the experiment
|
||||
|
||||
```shell
|
||||
coder server --experiments=chat-advisor
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
|
||||
```shell
|
||||
CODER_EXPERIMENTS=chat-advisor
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
Lets a root agent pause its current turn and request strategic guidance from
|
||||
a separate, single-step model call. The advisor sees recent conversation
|
||||
context, runs without any tools, and returns concise advice for the root
|
||||
agent rather than the end user. While active, it is the only tool the root
|
||||
agent can call for that turn.
|
||||
|
||||
Useful for planning ambiguity, architectural tradeoffs, debugging strategy
|
||||
after repeated failures, or risk reduction before a destructive operation.
|
||||
|
||||
## Configuration
|
||||
|
||||
Once the experiment is enabled, configure the advisor under **AI Settings** >
|
||||
**Coder Agents** > **Advisor**.
|
||||
|
||||
| Field | Default | Notes |
|
||||
|-------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------|
|
||||
| Max uses per run | `0` (unlimited) | Caps how many times the root agent can call the advisor in a single chat run. Must be a non-negative integer. |
|
||||
| Max output tokens | `0` (server default) | Caps the advisor model's response length. `0` uses the server default of 16,384 tokens. Must be a non-negative integer. |
|
||||
| Advisor model | Use chat model | Optional dedicated chat model config for the advisor. When unset, the advisor reuses the root agent's model. |
|
||||
|
||||
The advisor is not available in plan mode or to subagents. Failed advisor
|
||||
invocations refund the per-run budget, and advisor calls are not metered
|
||||
against the root chat's usage limit.
|
||||
|
||||
The same configuration is available at:
|
||||
|
||||
- `GET /api/experimental/chats/config/advisor`
|
||||
- `PUT /api/experimental/chats/config/advisor`
|
||||
+3
-59
@@ -1,60 +1,4 @@
|
||||
# Experiments
|
||||
|
||||
The **Experiments** tab under **Agents** > **Settings** > **Manage Agents**
|
||||
is where administrators opt in to features that are still iterating. The
|
||||
behavior, configuration surface, and APIs documented here may change between
|
||||
releases without notice.
|
||||
|
||||
> [!NOTE]
|
||||
> Everything in this page is experimental. Pin a release before broad rollout
|
||||
> and review the release notes before upgrading.
|
||||
|
||||
## Virtual desktop
|
||||
|
||||
Lets agents drive a graphical desktop inside the workspace through
|
||||
`spawn_agent` with `type=computer_use` (screenshots, mouse, keyboard).
|
||||
|
||||
To enable, toggle **Virtual Desktop** on, then choose a **Computer use
|
||||
provider** (Anthropic or OpenAI). It also requires:
|
||||
|
||||
- The [portabledesktop](https://registry.coder.com/modules/coder/portabledesktop)
|
||||
module installed in the workspace template.
|
||||
- An API key for the selected provider configured under the **Providers**
|
||||
tab.
|
||||
|
||||
The Anthropic and OpenAI computer-use models are fixed by Coder per provider
|
||||
and are not selectable from this UI. Anthropic is the default when no
|
||||
provider is set.
|
||||
|
||||
## Advisor
|
||||
|
||||
Lets a root agent pause its current turn and request strategic guidance from
|
||||
a separate, single-step model call. The advisor sees recent conversation
|
||||
context, runs without any tools, and returns concise advice for the parent
|
||||
agent rather than the end user. While active, it is the only tool the parent
|
||||
can call for that turn.
|
||||
|
||||
Useful for planning ambiguity, architectural tradeoffs, debugging strategy
|
||||
after repeated failures, or risk reduction before a destructive operation.
|
||||
|
||||
| Field | Default | Notes |
|
||||
|-------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------|
|
||||
| Advisor (toggle) | Off | Master switch. When off, the advisor tool is not attached to new chats. |
|
||||
| Max uses per run | `0` (unlimited) | Caps how many times an agent can call the advisor in a single chat run. Must be a non-negative integer. |
|
||||
| Max output tokens | `0` (server default) | Caps the advisor model's response length. `0` uses the server default of 16,384 tokens. Must be a non-negative integer. |
|
||||
| Reasoning effort | Use chat model | One of unset, `low`, `medium`, or `high`. Unset delegates to the underlying model's default. |
|
||||
| Advisor model | Use chat model | Optional dedicated chat model config for the advisor. When unset, the advisor reuses the parent chat's model. |
|
||||
|
||||
The advisor is not available in plan mode or to subagents. Failed advisor
|
||||
invocations refund the per-run budget, and advisor calls are not metered
|
||||
against the parent chat's usage limit.
|
||||
|
||||
The same configuration is available at:
|
||||
|
||||
- `GET /api/experimental/chats/config/advisor`
|
||||
- `PUT /api/experimental/chats/config/advisor`
|
||||
|
||||
## Chat debug logging
|
||||
# Chat debug logging
|
||||
|
||||
Records a detailed trace of each chat turn for troubleshooting: the
|
||||
normalized request sent to the LLM provider, the full response, token usage,
|
||||
@@ -67,7 +11,7 @@ Off by default. Three layers control whether it runs for a given chat:
|
||||
on for every chat. The runtime admin and user toggles become read-only.
|
||||
1. **Runtime admin gate.** With the deployment override unset, the
|
||||
*Let users record chat debug logs* toggle decides whether users can opt
|
||||
in. Configure it at
|
||||
in. Configure it under **AI Settings** > **Lifecycle**, or at
|
||||
`GET/PUT /api/experimental/chats/config/debug-logging`.
|
||||
1. **Per-user toggle.** Users with the admin gate enabled can turn debug
|
||||
logging on for their own chats from **Agents** > **Settings** > **General**
|
||||
@@ -89,7 +33,7 @@ that chat's owner. The tab lists recent debug runs and lets you expand a run
|
||||
into its per-step request, response, token usage, retry attempts, errors,
|
||||
and policy metadata.
|
||||
|
||||
### Export debug logs
|
||||
## Export debug logs
|
||||
|
||||
You can export the same captured debug data from the UI:
|
||||
|
||||
@@ -144,12 +144,17 @@ for details.
|
||||
|
||||
### Experiments
|
||||
|
||||
Administrators can opt in to experimental features under **Agents** >
|
||||
**Settings** > **Manage Agents** > **Experiments**. Behavior, configuration
|
||||
surface, and APIs may change between releases.
|
||||
Administrators enable experimental features using the `--experiments` flag on
|
||||
`coder server` (or the `CODER_EXPERIMENTS` environment variable). Once enabled,
|
||||
runtime configuration for those features is available under **AI Settings** >
|
||||
**Coder Agents**.
|
||||
|
||||
See [Experiments](./experiments.md) for the current list of experiments, how
|
||||
to enable them, and the relevant API endpoints.
|
||||
See the following pages for experiment-gated features:
|
||||
|
||||
- [Advisor](./advisor.md) (`--experiments=chat-advisor`)
|
||||
- [Virtual desktop](./virtual-desktop.md) (`--experiments=chat-virtual-desktop`)
|
||||
|
||||
For chat debug logging (not experiment-gated), see [Chat debug logging](./chat-debug-logging.md).
|
||||
|
||||
## Where we are headed
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Virtual desktop
|
||||
|
||||
> [!NOTE]
|
||||
> This feature is experimental. Pin a release before broad rollout and review
|
||||
> the release notes before upgrading.
|
||||
|
||||
## Enable the experiment
|
||||
|
||||
```shell
|
||||
coder server --experiments=chat-virtual-desktop
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
|
||||
```shell
|
||||
CODER_EXPERIMENTS=chat-virtual-desktop
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
Lets agents drive a graphical desktop inside the workspace through
|
||||
`spawn_agent` with `type=computer_use` (screenshots, mouse, keyboard).
|
||||
|
||||
## Configuration
|
||||
|
||||
Once the experiment is enabled, configure the computer-use provider under
|
||||
**AI Settings** > **Coder Agents** > **Virtual desktop**.
|
||||
|
||||
Choose a **Computer use provider** (Anthropic or OpenAI). Virtual desktop also requires:
|
||||
|
||||
- The [portabledesktop](https://registry.coder.com/modules/coder/portabledesktop)
|
||||
module installed in the workspace template.
|
||||
- An API key for the selected provider configured under the **Providers**
|
||||
tab.
|
||||
|
||||
The Anthropic and OpenAI computer-use models are fixed by Coder per provider
|
||||
and are not selectable from this UI. Anthropic is the default when no
|
||||
provider is set.
|
||||
|
||||
The same configuration is available at:
|
||||
|
||||
- `GET /api/experimental/chats/config/computer-use-provider`
|
||||
- `PUT /api/experimental/chats/config/computer-use-provider`
|
||||
+16
-4
@@ -1080,10 +1080,22 @@
|
||||
"state": ["beta"]
|
||||
},
|
||||
{
|
||||
"title": "Experiments",
|
||||
"description": "Experimental Coder Agents features admins can opt in to: virtual desktop, advisor, and chat debug logging",
|
||||
"path": "./ai-coder/agents/platform-controls/experiments.md",
|
||||
"state": ["beta"]
|
||||
"title": "Advisor",
|
||||
"description": "Strategic guidance for root agent chats",
|
||||
"path": "./ai-coder/agents/platform-controls/advisor.md",
|
||||
"state": ["early access"]
|
||||
},
|
||||
{
|
||||
"title": "Virtual desktop",
|
||||
"description": "Allow agents to use a graphical desktop within workspaces",
|
||||
"path": "./ai-coder/agents/platform-controls/virtual-desktop.md",
|
||||
"state": ["early access"]
|
||||
},
|
||||
{
|
||||
"title": "Chat debug logging",
|
||||
"description": "Record detailed traces of chat turns for troubleshooting",
|
||||
"path": "./ai-coder/agents/platform-controls/chat-debug-logging.md",
|
||||
"state": ["early access"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+3
-3
@@ -7087,9 +7087,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value(s) |
|
||||
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `agent-app-tabs`, `ai-gateway-cost-control`, `auto-fill-parameters`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` |
|
||||
| Value(s) |
|
||||
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `agent-app-tabs`, `ai-gateway-cost-control`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` |
|
||||
|
||||
## codersdk.ExternalAPIKeyScopes
|
||||
|
||||
|
||||
@@ -3724,20 +3724,6 @@ class ExperimentalApiMethods {
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
getChatDesktopEnabled =
|
||||
async (): Promise<TypesGen.ChatDesktopEnabledResponse> => {
|
||||
const response =
|
||||
await this.axios.get<TypesGen.ChatDesktopEnabledResponse>(
|
||||
"/api/experimental/chats/config/desktop-enabled",
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
updateChatDesktopEnabled = async (
|
||||
req: TypesGen.UpdateChatDesktopEnabledRequest,
|
||||
): Promise<void> => {
|
||||
await this.axios.put("/api/experimental/chats/config/desktop-enabled", req);
|
||||
};
|
||||
|
||||
getChatAdvisorConfig = async (): Promise<AdvisorConfig> => {
|
||||
const response = await this.axios.get<AdvisorConfig>(
|
||||
|
||||
@@ -1581,22 +1581,6 @@ export const updateChatPlanModeInstructions = (queryClient: QueryClient) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const chatDesktopEnabledKey = ["chat-desktop-enabled"] as const;
|
||||
|
||||
export const chatDesktopEnabled = () => ({
|
||||
queryKey: chatDesktopEnabledKey,
|
||||
queryFn: () => API.experimental.getChatDesktopEnabled(),
|
||||
});
|
||||
|
||||
export const updateChatDesktopEnabled = (queryClient: QueryClient) => ({
|
||||
mutationFn: API.experimental.updateChatDesktopEnabled,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: chatDesktopEnabledKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const chatPersonalModelOverridesAdminSettingsKey = [
|
||||
...chatsKey,
|
||||
"admin-personal-model-overrides",
|
||||
|
||||
Generated
+10
-20
@@ -1002,8 +1002,9 @@ export const Addons: Addon[] = ["ai_governance"];
|
||||
*/
|
||||
export interface AdvisorConfig {
|
||||
/**
|
||||
* Enabled toggles the advisor runtime. When false, advisor is not
|
||||
* attached to new chats.
|
||||
* Enabled reflects whether the chat-advisor experiment is active.
|
||||
* The experiment flag is the sole gate; this field is read-only and
|
||||
* always matches the experiment state regardless of the stored DB value.
|
||||
*/
|
||||
readonly enabled: boolean;
|
||||
/**
|
||||
@@ -2054,14 +2055,6 @@ export const ChatDebugStepOperations: ChatDebugStepOperation[] = [
|
||||
"stream",
|
||||
];
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* ChatDesktopEnabledResponse is the response for getting the desktop setting.
|
||||
*/
|
||||
export interface ChatDesktopEnabledResponse {
|
||||
readonly enable_desktop: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* ChatDiffContents represents the resolved diff text for a chat.
|
||||
@@ -4595,6 +4588,8 @@ export type Experiment =
|
||||
| "ai-gateway-cost-control"
|
||||
| "agent-app-tabs"
|
||||
| "auto-fill-parameters"
|
||||
| "chat-advisor"
|
||||
| "chat-virtual-desktop"
|
||||
| "example"
|
||||
| "mcp-server-http"
|
||||
| "minimum-implicit-member"
|
||||
@@ -4608,6 +4603,8 @@ export const Experiments: Experiment[] = [
|
||||
"ai-gateway-cost-control",
|
||||
"agent-app-tabs",
|
||||
"auto-fill-parameters",
|
||||
"chat-advisor",
|
||||
"chat-virtual-desktop",
|
||||
"example",
|
||||
"mcp-server-http",
|
||||
"minimum-implicit-member",
|
||||
@@ -8822,8 +8819,9 @@ export interface UpdateActiveTemplateVersion {
|
||||
*/
|
||||
export interface UpdateAdvisorConfigRequest {
|
||||
/**
|
||||
* Enabled toggles the advisor runtime. When false, advisor is not
|
||||
* attached to new chats.
|
||||
* Enabled reflects whether the chat-advisor experiment is active.
|
||||
* The experiment flag is the sole gate; this field is read-only and
|
||||
* always matches the experiment state regardless of the stored DB value.
|
||||
*/
|
||||
readonly enabled: boolean;
|
||||
/**
|
||||
@@ -8899,14 +8897,6 @@ export interface UpdateChatDebugRetentionDaysRequest {
|
||||
readonly debug_retention_days: number;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* UpdateChatDesktopEnabledRequest is the request to update the desktop setting.
|
||||
*/
|
||||
export interface UpdateChatDesktopEnabledRequest {
|
||||
readonly enable_desktop: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* UpdateChatModelConfigRequest updates a chat model config.
|
||||
|
||||
@@ -7,12 +7,17 @@ import {
|
||||
} from "react-query";
|
||||
import { API } from "#/api/api";
|
||||
import {
|
||||
chatAdvisorConfig,
|
||||
chatComputerUseProvider,
|
||||
chatModelConfigs,
|
||||
chatPersonalModelOverridesAdminSettings,
|
||||
updateChatAdvisorConfig,
|
||||
updateChatComputerUseProvider,
|
||||
updateChatPersonalModelOverridesAdminSettings,
|
||||
} from "#/api/queries/chats";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { CoderAgentsPageView } from "./CoderAgentsPageView";
|
||||
@@ -48,8 +53,13 @@ const updateChatModelOverrideMutation = (
|
||||
|
||||
const CoderAgentsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { experiments } = useDashboard();
|
||||
const queryClient = useQueryClient();
|
||||
const canEditDeploymentConfig = permissions.editDeploymentConfig;
|
||||
const showAdvisorSettings = experiments.includes("chat-advisor");
|
||||
const showVirtualDesktopSettings = experiments.includes(
|
||||
"chat-virtual-desktop",
|
||||
);
|
||||
|
||||
const personalModelOverridesAdminSettingsQuery = useQuery({
|
||||
...chatPersonalModelOverridesAdminSettings(),
|
||||
@@ -68,6 +78,14 @@ const CoderAgentsPage: FC = () => {
|
||||
enabled: canEditDeploymentConfig,
|
||||
});
|
||||
const modelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const advisorConfigQuery = useQuery({
|
||||
...chatAdvisorConfig(),
|
||||
enabled: canEditDeploymentConfig && showAdvisorSettings,
|
||||
});
|
||||
const computerUseProviderQuery = useQuery({
|
||||
...chatComputerUseProvider(),
|
||||
enabled: canEditDeploymentConfig && showVirtualDesktopSettings,
|
||||
});
|
||||
const savePersonalModelOverridesAdminSettingsMutation = useMutation(
|
||||
updateChatPersonalModelOverridesAdminSettings(queryClient),
|
||||
);
|
||||
@@ -83,6 +101,12 @@ const CoderAgentsPage: FC = () => {
|
||||
const saveExploreModelOverrideMutation = useMutation(
|
||||
updateChatModelOverrideMutation(queryClient, exploreOverrideContext),
|
||||
);
|
||||
const saveAdvisorConfigMutation = useMutation(
|
||||
updateChatAdvisorConfig(queryClient),
|
||||
);
|
||||
const saveComputerUseProviderMutation = useMutation(
|
||||
updateChatComputerUseProvider(queryClient),
|
||||
);
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={canEditDeploymentConfig}>
|
||||
@@ -111,6 +135,7 @@ const CoderAgentsPage: FC = () => {
|
||||
modelConfigsData={modelConfigsQuery.data}
|
||||
modelConfigsError={modelConfigsQuery.error}
|
||||
isLoadingModelConfigs={modelConfigsQuery.isLoading}
|
||||
isFetchingModelConfigs={modelConfigsQuery.isFetching}
|
||||
onSaveGeneralModelOverride={saveGeneralModelOverrideMutation.mutate}
|
||||
isSavingGeneralModelOverride={
|
||||
saveGeneralModelOverrideMutation.isPending
|
||||
@@ -132,6 +157,21 @@ const CoderAgentsPage: FC = () => {
|
||||
isSaveExploreModelOverrideError={
|
||||
saveExploreModelOverrideMutation.isError
|
||||
}
|
||||
showAdvisorSettings={showAdvisorSettings}
|
||||
advisorConfigData={advisorConfigQuery.data}
|
||||
isAdvisorConfigLoading={advisorConfigQuery.isLoading}
|
||||
isAdvisorConfigFetching={advisorConfigQuery.isFetching}
|
||||
isAdvisorConfigLoadError={advisorConfigQuery.isError}
|
||||
onSaveAdvisorConfig={saveAdvisorConfigMutation.mutate}
|
||||
isSavingAdvisorConfig={saveAdvisorConfigMutation.isPending}
|
||||
isSaveAdvisorConfigError={saveAdvisorConfigMutation.isError}
|
||||
saveAdvisorConfigError={saveAdvisorConfigMutation.error}
|
||||
showVirtualDesktopSettings={showVirtualDesktopSettings}
|
||||
computerUseProviderData={computerUseProviderQuery.data}
|
||||
isLoadingComputerUseProvider={computerUseProviderQuery.isLoading}
|
||||
onSaveComputerUseProvider={saveComputerUseProviderMutation.mutate}
|
||||
isSavingComputerUseProvider={saveComputerUseProviderMutation.isPending}
|
||||
computerUseProviderSaveError={saveComputerUseProviderMutation.error}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
|
||||
@@ -120,6 +120,7 @@ const buildArgs = (
|
||||
modelConfigsData: allModelConfigs,
|
||||
modelConfigsError: undefined,
|
||||
isLoadingModelConfigs: false,
|
||||
isFetchingModelConfigs: false,
|
||||
onSaveGeneralModelOverride: fn(),
|
||||
isSavingGeneralModelOverride: false,
|
||||
isSaveGeneralModelOverrideError: false,
|
||||
@@ -129,6 +130,21 @@ const buildArgs = (
|
||||
onSaveExploreModelOverride: fn(),
|
||||
isSavingExploreModelOverride: false,
|
||||
isSaveExploreModelOverrideError: false,
|
||||
showAdvisorSettings: false,
|
||||
advisorConfigData: undefined,
|
||||
isAdvisorConfigLoading: false,
|
||||
isAdvisorConfigFetching: false,
|
||||
isAdvisorConfigLoadError: false,
|
||||
onSaveAdvisorConfig: fn(),
|
||||
isSavingAdvisorConfig: false,
|
||||
isSaveAdvisorConfigError: false,
|
||||
saveAdvisorConfigError: undefined,
|
||||
showVirtualDesktopSettings: false,
|
||||
computerUseProviderData: undefined,
|
||||
isLoadingComputerUseProvider: false,
|
||||
onSaveComputerUseProvider: fn(),
|
||||
isSavingComputerUseProvider: false,
|
||||
computerUseProviderSaveError: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -465,3 +481,121 @@ export const UnavailableSavedModels: Story = {
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const AdvisorSettingsVisible: Story = {
|
||||
args: buildArgs({
|
||||
showAdvisorSettings: true,
|
||||
advisorConfigData: {
|
||||
enabled: true,
|
||||
max_uses_per_run: 3,
|
||||
max_output_tokens: 16384,
|
||||
model_config_id: "00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
}),
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const section = await getSection(canvasElement, "Advisor");
|
||||
expect(
|
||||
within(section).getByRole("spinbutton", {
|
||||
name: "Max uses per run",
|
||||
}),
|
||||
).toHaveValue(3);
|
||||
expect(
|
||||
within(section).getByRole("spinbutton", {
|
||||
name: "Max output tokens",
|
||||
}),
|
||||
).toHaveValue(16384);
|
||||
expect(
|
||||
within(section).getByRole("combobox", { name: "Advisor model" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Changing a value exposes the Save button.
|
||||
const maxUses = within(section).getByRole("spinbutton", {
|
||||
name: "Max uses per run",
|
||||
});
|
||||
await userEvent.clear(maxUses);
|
||||
await userEvent.type(maxUses, "5");
|
||||
const saveButton = within(section).getByRole("button", { name: "Save" });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAdvisorConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ max_uses_per_run: 5 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const AdvisorClearButton: Story = {
|
||||
args: buildArgs({
|
||||
showAdvisorSettings: true,
|
||||
advisorConfigData: {
|
||||
enabled: true,
|
||||
max_uses_per_run: 3,
|
||||
max_output_tokens: 16384,
|
||||
model_config_id: generalModelConfig.id,
|
||||
},
|
||||
}),
|
||||
play: async ({ canvasElement }) => {
|
||||
const section = await getSection(canvasElement, "Advisor");
|
||||
const clearButton = within(section).getByRole("button", { name: "Clear" });
|
||||
await userEvent.click(clearButton);
|
||||
expect(
|
||||
within(section).getByRole("spinbutton", {
|
||||
name: "Max uses per run",
|
||||
}),
|
||||
).toHaveValue(0);
|
||||
expect(
|
||||
within(section).getByRole("spinbutton", {
|
||||
name: "Max output tokens",
|
||||
}),
|
||||
).toHaveValue(0);
|
||||
expect(
|
||||
within(section).getByRole("combobox", { name: "Advisor model" }),
|
||||
).toHaveTextContent("Use chat model");
|
||||
},
|
||||
};
|
||||
|
||||
export const VirtualDesktopSettingsVisible: Story = {
|
||||
args: buildArgs({
|
||||
showVirtualDesktopSettings: true,
|
||||
computerUseProviderData: { provider: "anthropic" },
|
||||
}),
|
||||
play: async ({ canvasElement }) => {
|
||||
const section = await getSection(canvasElement, "Virtual desktop");
|
||||
expect(
|
||||
within(section).getByRole("combobox", {
|
||||
name: "Computer use provider",
|
||||
}),
|
||||
).toHaveTextContent("Anthropic");
|
||||
},
|
||||
};
|
||||
|
||||
export const VirtualDesktopProviderChange: Story = {
|
||||
args: buildArgs({
|
||||
showVirtualDesktopSettings: true,
|
||||
computerUseProviderData: { provider: "anthropic" },
|
||||
}),
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const section = await getSection(canvasElement, "Virtual desktop");
|
||||
const trigger = within(section).getByRole("combobox", {
|
||||
name: "Computer use provider",
|
||||
});
|
||||
await userEvent.click(trigger);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await body.findByRole("option", { name: "OpenAI" }));
|
||||
const saveButton = within(section).getByRole("button", { name: "Save" });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveComputerUseProvider).toHaveBeenCalledWith(
|
||||
{ provider: "openai" },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { FC } from "react";
|
||||
import type { UseMutateFunction } from "react-query";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { AdvisorSettings } from "#/pages/AgentsPage/components/AdvisorSettings";
|
||||
import { VirtualDesktopSettings } from "#/pages/AgentsPage/components/VirtualDesktopSettings";
|
||||
import {
|
||||
AdminPersonalModelOverridesSettings,
|
||||
type SavePersonalModelOverridesAdminSetting,
|
||||
@@ -33,6 +36,7 @@ export interface CoderAgentsPageViewProps {
|
||||
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
|
||||
modelConfigsError: unknown;
|
||||
isLoadingModelConfigs: boolean;
|
||||
isFetchingModelConfigs: boolean;
|
||||
onSaveGeneralModelOverride?: SaveModelOverride;
|
||||
isSavingGeneralModelOverride?: boolean;
|
||||
isSaveGeneralModelOverrideError?: boolean;
|
||||
@@ -42,6 +46,29 @@ export interface CoderAgentsPageViewProps {
|
||||
onSaveExploreModelOverride: SaveModelOverride;
|
||||
isSavingExploreModelOverride: boolean;
|
||||
isSaveExploreModelOverrideError: boolean;
|
||||
showAdvisorSettings: boolean;
|
||||
advisorConfigData: TypesGen.AdvisorConfig | undefined;
|
||||
isAdvisorConfigLoading: boolean;
|
||||
isAdvisorConfigFetching: boolean;
|
||||
isAdvisorConfigLoadError: boolean;
|
||||
onSaveAdvisorConfig: (
|
||||
req: TypesGen.UpdateAdvisorConfigRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingAdvisorConfig: boolean;
|
||||
isSaveAdvisorConfigError: boolean;
|
||||
saveAdvisorConfigError: unknown;
|
||||
showVirtualDesktopSettings: boolean;
|
||||
computerUseProviderData: TypesGen.ChatComputerUseProviderResponse | undefined;
|
||||
isLoadingComputerUseProvider: boolean;
|
||||
onSaveComputerUseProvider: UseMutateFunction<
|
||||
void,
|
||||
Error,
|
||||
TypesGen.UpdateChatComputerUseProviderRequest,
|
||||
unknown
|
||||
>;
|
||||
isSavingComputerUseProvider: boolean;
|
||||
computerUseProviderSaveError: Error | null;
|
||||
}
|
||||
|
||||
export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
|
||||
@@ -58,6 +85,7 @@ export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
|
||||
modelConfigsData,
|
||||
modelConfigsError,
|
||||
isLoadingModelConfigs,
|
||||
isFetchingModelConfigs,
|
||||
onSaveGeneralModelOverride,
|
||||
isSavingGeneralModelOverride = false,
|
||||
isSaveGeneralModelOverrideError = false,
|
||||
@@ -67,6 +95,21 @@ export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
|
||||
onSaveExploreModelOverride,
|
||||
isSavingExploreModelOverride,
|
||||
isSaveExploreModelOverrideError,
|
||||
showAdvisorSettings,
|
||||
advisorConfigData,
|
||||
isAdvisorConfigLoading,
|
||||
isAdvisorConfigFetching,
|
||||
isAdvisorConfigLoadError,
|
||||
onSaveAdvisorConfig,
|
||||
isSavingAdvisorConfig,
|
||||
isSaveAdvisorConfigError,
|
||||
saveAdvisorConfigError,
|
||||
showVirtualDesktopSettings,
|
||||
computerUseProviderData,
|
||||
isLoadingComputerUseProvider,
|
||||
onSaveComputerUseProvider,
|
||||
isSavingComputerUseProvider,
|
||||
computerUseProviderSaveError,
|
||||
}) => {
|
||||
const enabledModelConfigs = (modelConfigsData ?? []).filter(
|
||||
(modelConfig) => modelConfig.enabled,
|
||||
@@ -136,6 +179,31 @@ export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
|
||||
isSaveError={isSaveExploreModelOverrideError}
|
||||
saveErrorMessage="Failed to save Explore model override."
|
||||
/>
|
||||
{showVirtualDesktopSettings && (
|
||||
<VirtualDesktopSettings
|
||||
computerUseProviderData={computerUseProviderData}
|
||||
isLoadingComputerUseProvider={isLoadingComputerUseProvider}
|
||||
onSaveComputerUseProvider={onSaveComputerUseProvider}
|
||||
isSavingComputerUseProvider={isSavingComputerUseProvider}
|
||||
computerUseProviderSaveError={computerUseProviderSaveError}
|
||||
/>
|
||||
)}
|
||||
{showAdvisorSettings && (
|
||||
<AdvisorSettings
|
||||
advisorConfigData={advisorConfigData}
|
||||
isAdvisorConfigLoading={isAdvisorConfigLoading}
|
||||
isAdvisorConfigFetching={isAdvisorConfigFetching}
|
||||
isAdvisorConfigLoadError={isAdvisorConfigLoadError}
|
||||
modelConfigs={modelConfigsData ?? []}
|
||||
modelConfigsError={modelConfigsError}
|
||||
isLoadingModelConfigs={isLoadingModelConfigs}
|
||||
isFetchingModelConfigs={isFetchingModelConfigs}
|
||||
onSaveAdvisorConfig={onSaveAdvisorConfig}
|
||||
isSavingAdvisorConfig={isSavingAdvisorConfig}
|
||||
isSaveAdvisorConfigError={isSaveAdvisorConfigError}
|
||||
saveAdvisorConfigError={saveAdvisorConfigError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { FC } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import {
|
||||
chatAutoArchiveDays,
|
||||
chatDebugLogging,
|
||||
chatDebugRetentionDays,
|
||||
chatRetentionDays,
|
||||
chatWorkspaceTTL,
|
||||
updateChatAutoArchiveDays,
|
||||
updateChatDebugLogging,
|
||||
updateChatDebugRetentionDays,
|
||||
updateChatRetentionDays,
|
||||
updateChatWorkspaceTTL,
|
||||
@@ -34,6 +36,10 @@ const LifecyclePage: FC = () => {
|
||||
...chatDebugRetentionDays(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const debugLoggingQuery = useQuery({
|
||||
...chatDebugLogging(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const saveWorkspaceTTLMutation = useMutation(
|
||||
updateChatWorkspaceTTL(queryClient),
|
||||
);
|
||||
@@ -46,6 +52,9 @@ const LifecyclePage: FC = () => {
|
||||
const saveDebugRetentionDaysMutation = useMutation(
|
||||
updateChatDebugRetentionDays(queryClient),
|
||||
);
|
||||
const saveDebugLoggingMutation = useMutation(
|
||||
updateChatDebugLogging(queryClient),
|
||||
);
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
@@ -75,6 +84,11 @@ const LifecyclePage: FC = () => {
|
||||
onSaveAutoArchiveDays={saveAutoArchiveDaysMutation.mutate}
|
||||
isSavingAutoArchiveDays={saveAutoArchiveDaysMutation.isPending}
|
||||
isSaveAutoArchiveDaysError={saveAutoArchiveDaysMutation.isError}
|
||||
debugLoggingData={debugLoggingQuery.data}
|
||||
isDebugLoggingLoading={debugLoggingQuery.isLoading}
|
||||
onSaveDebugLogging={saveDebugLoggingMutation.mutate}
|
||||
isSavingDebugLogging={saveDebugLoggingMutation.isPending}
|
||||
isSaveDebugLoggingError={saveDebugLoggingMutation.isError}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,11 @@ const baseArgs: LifecyclePageViewProps = {
|
||||
onSaveAutoArchiveDays: fn(),
|
||||
isSavingAutoArchiveDays: false,
|
||||
isSaveAutoArchiveDaysError: false,
|
||||
debugLoggingData: undefined,
|
||||
isDebugLoggingLoading: false,
|
||||
onSaveDebugLogging: fn(),
|
||||
isSavingDebugLogging: false,
|
||||
isSaveDebugLoggingError: false,
|
||||
};
|
||||
|
||||
const meta = {
|
||||
@@ -633,3 +638,57 @@ export const DebugRetentionInputDisabledWhenToggleOff: Story = {
|
||||
expect(input).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const DirtyDebugLoggingToggle: Story = {
|
||||
args: {
|
||||
debugLoggingData: { allow_users: false, forced_by_deployment: false },
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = await canvas.findByRole("switch", {
|
||||
name: "Allow users to enable chat debug logging",
|
||||
});
|
||||
|
||||
await userEvent.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveDebugLogging).toHaveBeenCalledWith({
|
||||
allow_users: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DebugLoggingForcedByDeployment: Story = {
|
||||
args: {
|
||||
debugLoggingData: { allow_users: false, forced_by_deployment: true },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = await canvas.findByRole("switch", {
|
||||
name: "Allow users to enable chat debug logging",
|
||||
});
|
||||
expect(toggle).toBeDisabled();
|
||||
|
||||
expect(
|
||||
canvas.getByText(
|
||||
"Debug logging is already enabled deployment-wide, so this per-user setting has no effect right now.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const DebugLoggingSaveError: Story = {
|
||||
args: {
|
||||
debugLoggingData: { allow_users: false, forced_by_deployment: false },
|
||||
isSaveDebugLoggingError: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
"Failed to save the admin debug logging setting.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SettingsHeaderDescription,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { AdminChatDebugLoggingSettings } from "#/pages/AgentsPage/components/AdminChatDebugLoggingSettings";
|
||||
import { AutoArchiveSettings } from "./components/AutoArchiveSettings";
|
||||
import { DebugRetentionSettings } from "./components/DebugRetentionSettings";
|
||||
import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings";
|
||||
@@ -56,6 +57,16 @@ export interface LifecyclePageViewProps {
|
||||
>;
|
||||
isSavingAutoArchiveDays: boolean;
|
||||
isSaveAutoArchiveDaysError: boolean;
|
||||
debugLoggingData: TypesGen.ChatDebugLoggingAdminSettings | undefined;
|
||||
isDebugLoggingLoading: boolean;
|
||||
onSaveDebugLogging: UseMutateFunction<
|
||||
void,
|
||||
Error,
|
||||
TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
|
||||
unknown
|
||||
>;
|
||||
isSavingDebugLogging: boolean;
|
||||
isSaveDebugLoggingError: boolean;
|
||||
}
|
||||
|
||||
export const LifecyclePageView: FC<LifecyclePageViewProps> = ({
|
||||
@@ -83,6 +94,11 @@ export const LifecyclePageView: FC<LifecyclePageViewProps> = ({
|
||||
onSaveAutoArchiveDays,
|
||||
isSavingAutoArchiveDays,
|
||||
isSaveAutoArchiveDaysError,
|
||||
debugLoggingData,
|
||||
isDebugLoggingLoading,
|
||||
onSaveDebugLogging,
|
||||
isSavingDebugLogging,
|
||||
isSaveDebugLoggingError,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex max-w-[1100px] flex-col gap-4">
|
||||
@@ -125,6 +141,13 @@ export const LifecyclePageView: FC<LifecyclePageViewProps> = ({
|
||||
isSavingDebugRetentionDays={isSavingDebugRetentionDays}
|
||||
isSaveDebugRetentionDaysError={isSaveDebugRetentionDaysError}
|
||||
/>
|
||||
<AdminChatDebugLoggingSettings
|
||||
adminSettings={debugLoggingData}
|
||||
isLoadingAdminSetting={isDebugLoggingLoading}
|
||||
onSaveAdminSetting={onSaveDebugLogging}
|
||||
isSavingAdminSetting={isSavingDebugLogging}
|
||||
isSaveAdminSettingError={isSaveDebugLoggingError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1548,6 +1548,7 @@ export const WithSubagentCards: Story = {
|
||||
* that opens the right sidebar panel and switches to the Desktop tab. */
|
||||
export const WithComputerUseAgent: Story = {
|
||||
parameters: {
|
||||
experiments: ["chat-virtual-desktop"],
|
||||
queries: [
|
||||
...buildQueries(
|
||||
{
|
||||
@@ -1613,11 +1614,6 @@ export const WithComputerUseAgent: Story = {
|
||||
},
|
||||
{ diffUrl: undefined },
|
||||
),
|
||||
// Enable the desktop feature so the Desktop tab appears in the sidebar.
|
||||
{
|
||||
key: ["chat-desktop-enabled"],
|
||||
data: { enable_desktop: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -2476,6 +2472,7 @@ export const WithEveryTool: Story = {
|
||||
* (SubagentTool with computer-use variant) instead of the plain SubagentTool card. */
|
||||
export const WithWaitAgentComputerUseVNC: Story = {
|
||||
parameters: {
|
||||
experiments: ["chat-virtual-desktop"],
|
||||
queries: [
|
||||
...buildQueries(
|
||||
{
|
||||
@@ -2519,10 +2516,6 @@ export const WithWaitAgentComputerUseVNC: Story = {
|
||||
},
|
||||
{ diffUrl: undefined },
|
||||
),
|
||||
{
|
||||
key: ["chat-desktop-enabled"],
|
||||
data: { enable_desktop: true },
|
||||
},
|
||||
],
|
||||
// The wait_agent arrives via WebSocket so it renders in
|
||||
// the streaming/running state (no tool-result yet).
|
||||
|
||||
@@ -19,7 +19,6 @@ import { checkAuthorization } from "#/api/queries/authCheck";
|
||||
import { buildOptimisticEditedMessage } from "#/api/queries/chatMessageEdits";
|
||||
import {
|
||||
chat,
|
||||
chatDesktopEnabled,
|
||||
chatKey,
|
||||
chatMessagesForInfiniteScroll,
|
||||
chatModelConfigs,
|
||||
@@ -718,7 +717,7 @@ const AgentChatPage: FC = () => {
|
||||
} = useOutletContext<AgentsOutletContext>();
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions, user: currentUser } = useAuthenticated();
|
||||
const { organizations } = useDashboard();
|
||||
const { organizations, experiments } = useDashboard();
|
||||
const organizationName = getDefaultOrganizationName(organizations);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const scrollToBottomRef = useRef<(() => void) | null>(null);
|
||||
@@ -779,7 +778,6 @@ const AgentChatPage: FC = () => {
|
||||
});
|
||||
const userThresholdsQuery = useQuery(userCompactionThresholds());
|
||||
const preferencesQuery = useQuery(preferenceSettings());
|
||||
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
|
||||
const userDebugLoggingQuery = useQuery(userChatDebugLogging());
|
||||
const mcpServersQuery = useQuery(mcpServerConfigs());
|
||||
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
|
||||
@@ -788,7 +786,7 @@ const AgentChatPage: FC = () => {
|
||||
workspace,
|
||||
currentUser.id,
|
||||
);
|
||||
const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false;
|
||||
const desktopEnabled = experiments.includes("chat-virtual-desktop");
|
||||
const debugLoggingEnabled =
|
||||
userDebugLoggingQuery.data?.debug_logging_enabled ?? false;
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import {
|
||||
chatAdvisorConfig,
|
||||
chatComputerUseProvider,
|
||||
chatDebugLogging,
|
||||
chatDesktopEnabled,
|
||||
chatModelConfigs,
|
||||
updateChatAdvisorConfig,
|
||||
updateChatComputerUseProvider,
|
||||
updateChatDebugLogging,
|
||||
updateChatDesktopEnabled,
|
||||
} from "#/api/queries/chats";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { AgentSettingsExperimentsPageView } from "./AgentSettingsExperimentsPageView";
|
||||
|
||||
const AgentSettingsExperimentsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const queryClient = useQueryClient();
|
||||
const desktopEnabledQuery = useQuery({
|
||||
...chatDesktopEnabled(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const computerUseProviderQuery = useQuery({
|
||||
...chatComputerUseProvider(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const debugLoggingQuery = useQuery({
|
||||
...chatDebugLogging(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const advisorConfigQuery = useQuery({
|
||||
...chatAdvisorConfig(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const modelConfigsQuery = useQuery({
|
||||
...chatModelConfigs(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const saveDesktopEnabledMutation = useMutation(
|
||||
updateChatDesktopEnabled(queryClient),
|
||||
);
|
||||
const saveComputerUseProviderMutation = useMutation(
|
||||
updateChatComputerUseProvider(queryClient),
|
||||
);
|
||||
const saveDebugLoggingMutation = useMutation(
|
||||
updateChatDebugLogging(queryClient),
|
||||
);
|
||||
const saveAdvisorConfigMutation = useMutation(
|
||||
updateChatAdvisorConfig(queryClient),
|
||||
);
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
<AgentSettingsExperimentsPageView
|
||||
desktopEnabledData={desktopEnabledQuery.data}
|
||||
isLoadingDesktopEnabled={desktopEnabledQuery.isLoading}
|
||||
onSaveDesktopEnabled={saveDesktopEnabledMutation.mutate}
|
||||
isSavingDesktopEnabled={saveDesktopEnabledMutation.isPending}
|
||||
isSaveDesktopEnabledError={saveDesktopEnabledMutation.isError}
|
||||
computerUseProviderData={computerUseProviderQuery.data}
|
||||
isLoadingComputerUseProvider={computerUseProviderQuery.isLoading}
|
||||
onSaveComputerUseProvider={saveComputerUseProviderMutation.mutate}
|
||||
isSavingComputerUseProvider={saveComputerUseProviderMutation.isPending}
|
||||
computerUseProviderSaveError={saveComputerUseProviderMutation.error}
|
||||
debugLoggingData={debugLoggingQuery.data}
|
||||
isLoadingDebugLogging={debugLoggingQuery.isLoading}
|
||||
onSaveDebugLogging={saveDebugLoggingMutation.mutate}
|
||||
isSavingDebugLogging={saveDebugLoggingMutation.isPending}
|
||||
isSaveDebugLoggingError={saveDebugLoggingMutation.isError}
|
||||
advisorConfigData={advisorConfigQuery.data}
|
||||
isAdvisorConfigLoading={advisorConfigQuery.isLoading}
|
||||
isAdvisorConfigFetching={advisorConfigQuery.isFetching}
|
||||
isAdvisorConfigLoadError={advisorConfigQuery.isError}
|
||||
modelConfigsData={modelConfigsQuery.data ?? []}
|
||||
modelConfigsError={modelConfigsQuery.error}
|
||||
isLoadingModelConfigs={modelConfigsQuery.isLoading}
|
||||
isFetchingModelConfigs={modelConfigsQuery.isFetching}
|
||||
onSaveAdvisorConfig={saveAdvisorConfigMutation.mutate}
|
||||
isSavingAdvisorConfig={saveAdvisorConfigMutation.isPending}
|
||||
isSaveAdvisorConfigError={saveAdvisorConfigMutation.isError}
|
||||
saveAdvisorConfigError={saveAdvisorConfigMutation.error}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentSettingsExperimentsPage;
|
||||
@@ -1,277 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import {
|
||||
AgentSettingsExperimentsPageView,
|
||||
type AgentSettingsExperimentsPageViewProps,
|
||||
} from "./AgentSettingsExperimentsPageView";
|
||||
|
||||
const baseArgs: AgentSettingsExperimentsPageViewProps = {
|
||||
desktopEnabledData: { enable_desktop: false },
|
||||
isLoadingDesktopEnabled: false,
|
||||
onSaveDesktopEnabled: fn(),
|
||||
isSavingDesktopEnabled: false,
|
||||
isSaveDesktopEnabledError: false,
|
||||
computerUseProviderData: { provider: "anthropic" },
|
||||
isLoadingComputerUseProvider: false,
|
||||
onSaveComputerUseProvider: fn(),
|
||||
isSavingComputerUseProvider: false,
|
||||
computerUseProviderSaveError: null,
|
||||
debugLoggingData: {
|
||||
allow_users: false,
|
||||
forced_by_deployment: false,
|
||||
},
|
||||
isLoadingDebugLogging: false,
|
||||
onSaveDebugLogging: fn(),
|
||||
isSavingDebugLogging: false,
|
||||
isSaveDebugLoggingError: false,
|
||||
advisorConfigData: {
|
||||
enabled: false,
|
||||
max_uses_per_run: 0,
|
||||
max_output_tokens: 0,
|
||||
model_config_id: "",
|
||||
},
|
||||
isAdvisorConfigLoading: false,
|
||||
isAdvisorConfigFetching: false,
|
||||
isAdvisorConfigLoadError: false,
|
||||
modelConfigsData: [],
|
||||
modelConfigsError: undefined,
|
||||
isLoadingModelConfigs: false,
|
||||
isFetchingModelConfigs: false,
|
||||
onSaveAdvisorConfig: fn(),
|
||||
isSavingAdvisorConfig: false,
|
||||
isSaveAdvisorConfigError: false,
|
||||
saveAdvisorConfigError: undefined,
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "pages/AgentsPage/AgentSettingsExperimentsPageView",
|
||||
component: AgentSettingsExperimentsPageView,
|
||||
args: baseArgs,
|
||||
} satisfies Meta<typeof AgentSettingsExperimentsPageView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentSettingsExperimentsPageView>;
|
||||
|
||||
const getComputerUseProviderSelect = async (canvasElement: HTMLElement) => {
|
||||
const canvas = within(canvasElement);
|
||||
return canvas.findByRole("combobox", {
|
||||
name: "Computer use provider",
|
||||
});
|
||||
};
|
||||
|
||||
const selectComputerUseProvider = async (
|
||||
canvasElement: HTMLElement,
|
||||
currentSelectionName: string,
|
||||
optionName: string,
|
||||
) => {
|
||||
const trigger = await getComputerUseProviderSelect(canvasElement);
|
||||
expect(trigger).toHaveTextContent(currentSelectionName);
|
||||
|
||||
await userEvent.click(trigger);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await body.findByRole("option", { name: optionName }));
|
||||
await waitFor(() => expect(trigger).toHaveTextContent(optionName));
|
||||
};
|
||||
|
||||
function InteractiveComputerUseProviderStory(
|
||||
args: AgentSettingsExperimentsPageViewProps,
|
||||
) {
|
||||
const [computerUseProviderData, setComputerUseProviderData] = useState(
|
||||
args.computerUseProviderData,
|
||||
);
|
||||
|
||||
return (
|
||||
<AgentSettingsExperimentsPageView
|
||||
{...args}
|
||||
computerUseProviderData={computerUseProviderData}
|
||||
onSaveComputerUseProvider={(request, options) => {
|
||||
if (options) {
|
||||
args.onSaveComputerUseProvider(request, options);
|
||||
} else {
|
||||
args.onSaveComputerUseProvider(request);
|
||||
}
|
||||
setComputerUseProviderData({ provider: request.provider });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const AllowUsersOff: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = await canvas.findByRole("switch", {
|
||||
name: "Allow users to enable chat debug logging",
|
||||
});
|
||||
|
||||
expect(
|
||||
await canvas.findByText("Let users record chat debug logs"),
|
||||
).toBeInTheDocument();
|
||||
expect(toggle).not.toBeChecked();
|
||||
},
|
||||
};
|
||||
|
||||
export const AllowUsersOn: Story = {
|
||||
args: {
|
||||
debugLoggingData: {
|
||||
allow_users: true,
|
||||
forced_by_deployment: false,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = await canvas.findByRole("switch", {
|
||||
name: "Allow users to enable chat debug logging",
|
||||
});
|
||||
|
||||
expect(toggle).toBeChecked();
|
||||
},
|
||||
};
|
||||
|
||||
export const ForcedByDeployment: Story = {
|
||||
args: {
|
||||
debugLoggingData: {
|
||||
allow_users: true,
|
||||
forced_by_deployment: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = await canvas.findByRole("switch", {
|
||||
name: "Allow users to enable chat debug logging",
|
||||
});
|
||||
|
||||
expect(toggle).toBeDisabled();
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
/Debug logging is already enabled deployment-wide/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const DesktopSetting: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText("Virtual desktop");
|
||||
await canvas.findByText(
|
||||
/Allow agents to use a virtual, graphical desktop within workspaces./i,
|
||||
);
|
||||
await canvas.findByRole("switch", { name: "Enable" });
|
||||
},
|
||||
};
|
||||
|
||||
export const VirtualDesktopLoading: Story = {
|
||||
args: {
|
||||
desktopEnabledData: undefined,
|
||||
isLoadingDesktopEnabled: true,
|
||||
computerUseProviderData: undefined,
|
||||
isLoadingComputerUseProvider: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// While loading, the Switch is replaced by a skeleton placeholder.
|
||||
expect(
|
||||
canvas.queryByRole("switch", { name: "Enable" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const providerSelect = await getComputerUseProviderSelect(canvasElement);
|
||||
expect(providerSelect).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const TogglesDesktop: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = await canvas.findByRole("switch", { name: "Enable" });
|
||||
|
||||
await userEvent.click(toggle);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveDesktopEnabled).toHaveBeenCalledWith({
|
||||
enable_desktop: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ComputerUseProviderAnthropic: Story = {
|
||||
args: {
|
||||
desktopEnabledData: { enable_desktop: true },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText("Computer use provider");
|
||||
const providerSelect = await getComputerUseProviderSelect(canvasElement);
|
||||
|
||||
expect(providerSelect).not.toBeDisabled();
|
||||
expect(providerSelect).toHaveTextContent("Anthropic");
|
||||
},
|
||||
};
|
||||
|
||||
export const ComputerUseProviderDisabledWhenDesktopDisabled: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const providerSelect = await getComputerUseProviderSelect(canvasElement);
|
||||
|
||||
expect(providerSelect).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectsOpenAIProvider: Story = {
|
||||
args: {
|
||||
desktopEnabledData: { enable_desktop: true },
|
||||
onSaveComputerUseProvider: fn(),
|
||||
},
|
||||
render: InteractiveComputerUseProviderStory,
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await selectComputerUseProvider(canvasElement, "Anthropic", "OpenAI");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveComputerUseProvider).toHaveBeenCalledWith({
|
||||
provider: "openai",
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectsAnthropicProvider: Story = {
|
||||
args: {
|
||||
desktopEnabledData: { enable_desktop: true },
|
||||
computerUseProviderData: { provider: "openai" },
|
||||
onSaveComputerUseProvider: fn(),
|
||||
},
|
||||
render: InteractiveComputerUseProviderStory,
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await selectComputerUseProvider(canvasElement, "OpenAI", "Anthropic");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveComputerUseProvider).toHaveBeenCalledWith({
|
||||
provider: "anthropic",
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ComputerUseProviderSaveError: Story = {
|
||||
args: {
|
||||
computerUseProviderSaveError: new Error("Failed to save."),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await canvas.findByText("Failed to save computer use provider."),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ComputerUseProviderSaving: Story = {
|
||||
args: {
|
||||
desktopEnabledData: { enable_desktop: true },
|
||||
isSavingComputerUseProvider: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const providerSelect = await getComputerUseProviderSelect(canvasElement);
|
||||
|
||||
expect(providerSelect).toBeDisabled();
|
||||
},
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import type { UseMutateFunction } from "react-query";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { AdminChatDebugLoggingSettings } from "./components/AdminChatDebugLoggingSettings";
|
||||
import { AdvisorSettings } from "./components/AdvisorSettings";
|
||||
import { SectionHeader } from "./components/SectionHeader";
|
||||
import { VirtualDesktopSettings } from "./components/VirtualDesktopSettings";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
export interface AgentSettingsExperimentsPageViewProps {
|
||||
desktopEnabledData: TypesGen.ChatDesktopEnabledResponse | undefined;
|
||||
isLoadingDesktopEnabled: boolean;
|
||||
onSaveDesktopEnabled: UseMutateFunction<
|
||||
void,
|
||||
Error,
|
||||
TypesGen.UpdateChatDesktopEnabledRequest,
|
||||
unknown
|
||||
>;
|
||||
isSavingDesktopEnabled: boolean;
|
||||
isSaveDesktopEnabledError: boolean;
|
||||
computerUseProviderData: TypesGen.ChatComputerUseProviderResponse | undefined;
|
||||
isLoadingComputerUseProvider: boolean;
|
||||
onSaveComputerUseProvider: UseMutateFunction<
|
||||
void,
|
||||
Error,
|
||||
TypesGen.UpdateChatComputerUseProviderRequest,
|
||||
unknown
|
||||
>;
|
||||
isSavingComputerUseProvider: boolean;
|
||||
computerUseProviderSaveError: Error | null;
|
||||
debugLoggingData: TypesGen.ChatDebugLoggingAdminSettings | undefined;
|
||||
isLoadingDebugLogging: boolean;
|
||||
onSaveDebugLogging: UseMutateFunction<
|
||||
void,
|
||||
Error,
|
||||
TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
|
||||
unknown
|
||||
>;
|
||||
isSavingDebugLogging: boolean;
|
||||
isSaveDebugLoggingError: boolean;
|
||||
advisorConfigData: TypesGen.AdvisorConfig | undefined;
|
||||
isAdvisorConfigLoading: boolean;
|
||||
isAdvisorConfigFetching: boolean;
|
||||
isAdvisorConfigLoadError: boolean;
|
||||
modelConfigsData: readonly TypesGen.ChatModelConfig[];
|
||||
modelConfigsError: unknown;
|
||||
isLoadingModelConfigs: boolean;
|
||||
isFetchingModelConfigs: boolean;
|
||||
onSaveAdvisorConfig: (
|
||||
req: TypesGen.UpdateAdvisorConfigRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingAdvisorConfig: boolean;
|
||||
isSaveAdvisorConfigError: boolean;
|
||||
saveAdvisorConfigError: unknown;
|
||||
}
|
||||
|
||||
export const AgentSettingsExperimentsPageView: FC<
|
||||
AgentSettingsExperimentsPageViewProps
|
||||
> = ({
|
||||
desktopEnabledData,
|
||||
isLoadingDesktopEnabled,
|
||||
onSaveDesktopEnabled,
|
||||
isSavingDesktopEnabled,
|
||||
isSaveDesktopEnabledError,
|
||||
computerUseProviderData,
|
||||
isLoadingComputerUseProvider,
|
||||
onSaveComputerUseProvider,
|
||||
isSavingComputerUseProvider,
|
||||
computerUseProviderSaveError,
|
||||
debugLoggingData,
|
||||
isLoadingDebugLogging,
|
||||
onSaveDebugLogging,
|
||||
isSavingDebugLogging,
|
||||
isSaveDebugLoggingError,
|
||||
advisorConfigData,
|
||||
isAdvisorConfigLoading,
|
||||
isAdvisorConfigFetching,
|
||||
isAdvisorConfigLoadError,
|
||||
modelConfigsData,
|
||||
modelConfigsError,
|
||||
isLoadingModelConfigs,
|
||||
isFetchingModelConfigs,
|
||||
onSaveAdvisorConfig,
|
||||
isSavingAdvisorConfig,
|
||||
isSaveAdvisorConfigError,
|
||||
saveAdvisorConfigError,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<SectionHeader
|
||||
label="Experiments"
|
||||
description="Opt in to experimental features."
|
||||
/>
|
||||
<VirtualDesktopSettings
|
||||
desktopEnabledData={desktopEnabledData}
|
||||
isLoadingDesktopEnabled={isLoadingDesktopEnabled}
|
||||
onSaveDesktopEnabled={onSaveDesktopEnabled}
|
||||
isSavingDesktopEnabled={isSavingDesktopEnabled}
|
||||
isSaveDesktopEnabledError={isSaveDesktopEnabledError}
|
||||
computerUseProviderData={computerUseProviderData}
|
||||
isLoadingComputerUseProvider={isLoadingComputerUseProvider}
|
||||
onSaveComputerUseProvider={onSaveComputerUseProvider}
|
||||
isSavingComputerUseProvider={isSavingComputerUseProvider}
|
||||
computerUseProviderSaveError={computerUseProviderSaveError}
|
||||
/>
|
||||
<AdvisorSettings
|
||||
advisorConfigData={advisorConfigData}
|
||||
isAdvisorConfigLoading={isAdvisorConfigLoading}
|
||||
isAdvisorConfigFetching={isAdvisorConfigFetching}
|
||||
isAdvisorConfigLoadError={isAdvisorConfigLoadError}
|
||||
modelConfigs={modelConfigsData}
|
||||
modelConfigsError={modelConfigsError}
|
||||
isLoadingModelConfigs={isLoadingModelConfigs}
|
||||
isFetchingModelConfigs={isFetchingModelConfigs}
|
||||
onSaveAdvisorConfig={onSaveAdvisorConfig}
|
||||
isSavingAdvisorConfig={isSavingAdvisorConfig}
|
||||
isSaveAdvisorConfigError={isSaveAdvisorConfigError}
|
||||
saveAdvisorConfigError={saveAdvisorConfigError}
|
||||
/>
|
||||
<AdminChatDebugLoggingSettings
|
||||
adminSettings={debugLoggingData}
|
||||
isLoadingAdminSetting={isLoadingDebugLogging}
|
||||
onSaveAdminSetting={onSaveDebugLogging}
|
||||
isSavingAdminSetting={isSavingDebugLogging}
|
||||
isSaveAdminSettingError={isSaveDebugLoggingError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -31,7 +31,6 @@ import { CoderAgentsPageView } from "../AISettingsPage/CoderAgentsPage/CoderAgen
|
||||
import AgentAnalyticsPage from "./AgentAnalyticsPage";
|
||||
import AgentCreatePage from "./AgentCreatePage";
|
||||
import AgentSettingsCompactionPage from "./AgentSettingsCompactionPage";
|
||||
import AgentSettingsExperimentsPage from "./AgentSettingsExperimentsPage";
|
||||
import AgentSettingsGeneralPage from "./AgentSettingsGeneralPage";
|
||||
import AgentSettingsPage from "./AgentSettingsPage";
|
||||
import { type AgentsOutletContext, AgentsPageView } from "./AgentsPageView";
|
||||
@@ -183,12 +182,28 @@ const AgentsRouteElement = () => (
|
||||
modelConfigsData={[]}
|
||||
modelConfigsError={undefined}
|
||||
isLoadingModelConfigs={false}
|
||||
isFetchingModelConfigs={false}
|
||||
onSaveTitleGenerationModel={fn()}
|
||||
isSavingTitleGenerationModel={false}
|
||||
isSaveTitleGenerationModelError={false}
|
||||
onSaveExploreModelOverride={fn()}
|
||||
isSavingExploreModelOverride={false}
|
||||
isSaveExploreModelOverrideError={false}
|
||||
showAdvisorSettings={false}
|
||||
advisorConfigData={undefined}
|
||||
isAdvisorConfigLoading={false}
|
||||
isAdvisorConfigFetching={false}
|
||||
isAdvisorConfigLoadError={false}
|
||||
onSaveAdvisorConfig={fn()}
|
||||
isSavingAdvisorConfig={false}
|
||||
isSaveAdvisorConfigError={false}
|
||||
saveAdvisorConfigError={undefined}
|
||||
showVirtualDesktopSettings={false}
|
||||
computerUseProviderData={undefined}
|
||||
isLoadingComputerUseProvider={false}
|
||||
onSaveComputerUseProvider={fn()}
|
||||
isSavingComputerUseProvider={false}
|
||||
computerUseProviderSaveError={null}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -207,13 +222,19 @@ const agentsRouting = {
|
||||
path: "instructions",
|
||||
element: <Navigate to="/ai/settings/instructions" replace />,
|
||||
},
|
||||
{ path: "experiments", element: <AgentSettingsExperimentsPage /> },
|
||||
{
|
||||
path: "lifecycle",
|
||||
element: <Navigate to="/ai/settings/lifecycle" replace />,
|
||||
},
|
||||
{ path: "admin", element: <AgentsRouteElement /> },
|
||||
{ path: "agents", element: <AgentsRouteElement /> },
|
||||
{
|
||||
path: "admin",
|
||||
element: <Navigate to="/agents/settings/coder-agents" replace />,
|
||||
},
|
||||
{ path: "coder-agents", element: <AgentsRouteElement /> },
|
||||
{
|
||||
path: "agents",
|
||||
element: <Navigate to="/agents/settings/coder-agents" replace />,
|
||||
},
|
||||
{
|
||||
path: "spend",
|
||||
element: <Navigate to="/ai/settings/spend" replace />,
|
||||
@@ -421,10 +442,6 @@ const meta: Meta<typeof AgentsPageView> = {
|
||||
},
|
||||
]);
|
||||
spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([]);
|
||||
spyOn(API.experimental, "getChatDesktopEnabled").mockResolvedValue({
|
||||
enable_desktop: false,
|
||||
});
|
||||
spyOn(API.experimental, "updateChatDesktopEnabled").mockResolvedValue();
|
||||
spyOn(API.experimental, "getChatDebugLogging").mockResolvedValue({
|
||||
allow_users: false,
|
||||
forced_by_deployment: false,
|
||||
@@ -1143,7 +1160,7 @@ export const OpensSettingsForNonAdmins: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const OpensExperimentsFromManageAgentsOnMobile: Story = {
|
||||
export const OpensCoderAgentsFromManageAgentsOnMobile: Story = {
|
||||
args: {
|
||||
isAgentsAdmin: true,
|
||||
},
|
||||
@@ -1160,12 +1177,12 @@ export const OpensExperimentsFromManageAgentsOnMobile: Story = {
|
||||
);
|
||||
|
||||
await expect(
|
||||
await screen.findByRole("heading", { name: "Experiments" }),
|
||||
await screen.findByRole("heading", { name: "Coder Agents" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SettingsViewExperimentsLink: Story = {
|
||||
export const SettingsViewCoderAgentsLink: Story = {
|
||||
args: {
|
||||
isAgentsAdmin: true,
|
||||
},
|
||||
@@ -1185,7 +1202,9 @@ export const SettingsViewExperimentsLink: Story = {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Opt in to experimental features."),
|
||||
screen.getByText(
|
||||
"Configure deployment-wide defaults for Coder Agents and agent-specific capabilities.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { FC } from "react";
|
||||
import type { UseMutateFunction } from "react-query";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Skeleton } from "#/components/Skeleton/Skeleton";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
interface AdminChatDebugLoggingSettingsProps {
|
||||
adminSettings: TypesGen.ChatDebugLoggingAdminSettings | undefined;
|
||||
isLoadingAdminSetting: boolean;
|
||||
onSaveAdminSetting: UseMutateFunction<
|
||||
void,
|
||||
Error,
|
||||
TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
|
||||
unknown
|
||||
>;
|
||||
onSaveAdminSetting: (
|
||||
req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
|
||||
) => void;
|
||||
isSavingAdminSetting: boolean;
|
||||
isSaveAdminSettingError: boolean;
|
||||
}
|
||||
@@ -29,52 +25,36 @@ export const AdminChatDebugLoggingSettings: FC<
|
||||
const forcedByDeployment = adminSettings?.forced_by_deployment ?? false;
|
||||
const adminAllowsUsers = adminSettings?.allow_users ?? false;
|
||||
|
||||
const description = forcedByDeployment
|
||||
? "Debug logging is already enabled deployment-wide, so this per-user setting has no effect right now."
|
||||
: "Lets users turn on debug logging for their own chats from their General settings. When on, Coder saves each chat turn along with the raw API requests and responses sent to the model provider.";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-sm font-semibold text-content-primary">
|
||||
<div className="flex items-start gap-3">
|
||||
<Switch
|
||||
checked={adminAllowsUsers}
|
||||
onCheckedChange={(checked) =>
|
||||
onSaveAdminSetting({ allow_users: checked })
|
||||
}
|
||||
aria-label="Allow users to enable chat debug logging"
|
||||
disabled={
|
||||
forcedByDeployment || isSavingAdminSetting || isLoadingAdminSetting
|
||||
}
|
||||
className={cn("mt-0.5")}
|
||||
/>
|
||||
<div className="flex max-w-[980px] flex-1 flex-col">
|
||||
<h3 className="m-0 text-sm font-normal leading-6 text-content-primary">
|
||||
Let users record chat debug logs
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
|
||||
{forcedByDeployment ? (
|
||||
<p className="m-0">
|
||||
Debug logging is already enabled deployment-wide, so this per-user
|
||||
setting has no effect right now.
|
||||
</p>
|
||||
) : (
|
||||
<p className="m-0">
|
||||
Lets users turn on debug logging for their own chats from their
|
||||
General settings. When on, Coder saves each chat turn along with
|
||||
the raw API requests and responses sent to the model provider.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoadingAdminSetting ? (
|
||||
<Skeleton className="h-5 w-10 rounded-full" aria-hidden="true" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={adminAllowsUsers}
|
||||
onCheckedChange={(checked) =>
|
||||
onSaveAdminSetting({ allow_users: checked })
|
||||
}
|
||||
aria-label="Allow users to enable chat debug logging"
|
||||
disabled={
|
||||
forcedByDeployment ||
|
||||
isSavingAdminSetting ||
|
||||
isLoadingAdminSetting
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isSaveAdminSettingError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save the admin debug logging setting.
|
||||
<p className="mt-1 mb-0 text-sm font-normal leading-6 text-content-secondary">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{isSaveAdminSettingError && (
|
||||
<p className="m-0 mt-2 text-xs text-content-destructive">
|
||||
Failed to save the admin debug logging setting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,802 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MockChatModelConfig } from "#/testHelpers/chatModels";
|
||||
import { AdvisorSettings } from "./AdvisorSettings";
|
||||
|
||||
const nilUUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
const mockModelConfigs: TypesGen.ChatModelConfig[] = [
|
||||
{
|
||||
...MockChatModelConfig,
|
||||
id: "model-1",
|
||||
model: "gpt-5",
|
||||
display_name: "GPT-5",
|
||||
is_default: true,
|
||||
compression_threshold: 80,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
...MockChatModelConfig,
|
||||
id: "model-2",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4",
|
||||
display_name: "Claude Sonnet 4",
|
||||
compression_threshold: 80,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
...MockChatModelConfig,
|
||||
id: "model-3",
|
||||
model: "gpt-3.5",
|
||||
display_name: "GPT-3.5 (Disabled)",
|
||||
enabled: false,
|
||||
context_limit: 16000,
|
||||
compression_threshold: 60,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultAdvisorConfig: TypesGen.AdvisorConfig = {
|
||||
enabled: false,
|
||||
max_uses_per_run: 0,
|
||||
max_output_tokens: 0,
|
||||
model_config_id: "",
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "pages/AgentsPage/AdvisorSettings",
|
||||
component: AdvisorSettings,
|
||||
args: {
|
||||
advisorConfigData: defaultAdvisorConfig,
|
||||
isAdvisorConfigLoading: false,
|
||||
isAdvisorConfigFetching: false,
|
||||
isAdvisorConfigLoadError: false,
|
||||
modelConfigs: mockModelConfigs,
|
||||
modelConfigsError: undefined,
|
||||
isLoadingModelConfigs: false,
|
||||
isFetchingModelConfigs: false,
|
||||
onSaveAdvisorConfig: fn((_req, options) => {
|
||||
options?.onSuccess?.();
|
||||
}),
|
||||
isSavingAdvisorConfig: false,
|
||||
isSaveAdvisorConfigError: false,
|
||||
saveAdvisorConfigError: undefined,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-3xl">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof AdvisorSettings>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const enableAdvisorSwitch = canvas.getByRole("switch", {
|
||||
name: /Enable advisor/i,
|
||||
});
|
||||
|
||||
expect(
|
||||
canvas.queryByRole("spinbutton", { name: /Max uses per run/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("combobox", { name: /Advisor model/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("combobox", { name: /Reasoning effort/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(enableAdvisorSwitch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByRole("spinbutton", { name: /Max uses per run/i }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
canvas.getByRole("combobox", { name: /Advisor model/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const Enabled: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(document.body);
|
||||
const maxUsesInput = await canvas.findByRole("spinbutton", {
|
||||
name: /Max uses per run/i,
|
||||
});
|
||||
const maxOutputTokensInput = canvas.getByRole("spinbutton", {
|
||||
name: /Max output tokens/i,
|
||||
});
|
||||
const advisorModelSelect = canvas.getByRole("combobox", {
|
||||
name: /Advisor model/i,
|
||||
});
|
||||
expect(
|
||||
canvas.queryByRole("combobox", { name: /Reasoning effort/i }),
|
||||
).not.toBeInTheDocument();
|
||||
const saveButton = canvas.getByRole("button", { name: /Save/i });
|
||||
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
await userEvent.clear(maxUsesInput);
|
||||
await userEvent.type(maxUsesInput, "5");
|
||||
await userEvent.clear(maxOutputTokensInput);
|
||||
await userEvent.type(maxOutputTokensInput, "2048");
|
||||
|
||||
await userEvent.click(advisorModelSelect);
|
||||
expect(
|
||||
body.queryByRole("option", { name: /GPT-3.5 \(Disabled\)/i }),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
await body.findByRole("option", { name: /Claude Sonnet 4/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAdvisorConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [request, options] = args.onSaveAdvisorConfig.mock.calls[0];
|
||||
expect(request).toEqual({
|
||||
enabled: true,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "model-2",
|
||||
});
|
||||
expect(typeof options?.onSuccess).toBe("function");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SaveWithUseChatModel: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const maxUsesInput = await canvas.findByRole("spinbutton", {
|
||||
name: /Max uses per run/i,
|
||||
});
|
||||
|
||||
expect(
|
||||
canvas.getByRole("combobox", { name: /Advisor model/i }),
|
||||
).toHaveTextContent(/Use chat model/i);
|
||||
|
||||
await userEvent.clear(maxUsesInput);
|
||||
await userEvent.type(maxUsesInput, "3");
|
||||
|
||||
const saveButton = canvas.getByRole("button", { name: /Save/i });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAdvisorConfig).toHaveBeenCalled();
|
||||
});
|
||||
const [request] = args.onSaveAdvisorConfig.mock.calls[0];
|
||||
expect(request.model_config_id).toBe(nilUUID);
|
||||
},
|
||||
};
|
||||
|
||||
export const NilUUIDInitialRoundTrip: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
model_config_id: nilUUID,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const advisorModelSelect = await canvas.findByRole("combobox", {
|
||||
name: /Advisor model/i,
|
||||
});
|
||||
|
||||
expect(advisorModelSelect).toHaveTextContent(/Use chat model/i);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomConfig: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
enabled: true,
|
||||
max_uses_per_run: 7,
|
||||
max_output_tokens: 8192,
|
||||
model_config_id: "model-2",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const maxUsesInput = await canvas.findByRole("spinbutton", {
|
||||
name: /Max uses per run/i,
|
||||
});
|
||||
const maxOutputTokensInput = canvas.getByRole("spinbutton", {
|
||||
name: /Max output tokens/i,
|
||||
});
|
||||
|
||||
expect(maxUsesInput).toHaveValue(7);
|
||||
expect(maxOutputTokensInput).toHaveValue(8192);
|
||||
expect(
|
||||
canvas.getByRole("combobox", { name: /Advisor model/i }),
|
||||
).toHaveTextContent(/Claude Sonnet 4/i);
|
||||
},
|
||||
};
|
||||
|
||||
export const UnavailableSelectedModel: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
model_config_id: "22222222-2222-2222-2222-222222222222",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const advisorModelSelect = await canvas.findByRole("combobox", {
|
||||
name: /Advisor model/i,
|
||||
});
|
||||
|
||||
expect(advisorModelSelect).toHaveTextContent(
|
||||
/Unavailable model \(22222222-2222-2222-2222-222222222222\)/i,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
advisorConfigData: undefined,
|
||||
isAdvisorConfigLoading: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(
|
||||
canvas.getByRole("switch", { name: /Enable advisor/i }),
|
||||
).toBeDisabled();
|
||||
expect(canvas.getByRole("button", { name: /Save/i })).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const Refetching: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
},
|
||||
isAdvisorConfigFetching: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(
|
||||
canvas.getByRole("switch", { name: /Enable advisor/i }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
canvas.getByRole("spinbutton", { name: /Max uses per run/i }),
|
||||
).toBeDisabled();
|
||||
expect(canvas.getByRole("button", { name: /Save/i })).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingModelConfigs: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
model_config_id: "model-2",
|
||||
},
|
||||
modelConfigs: [],
|
||||
isLoadingModelConfigs: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const advisorModelSelect = await canvas.findByRole("combobox", {
|
||||
name: /Advisor model/i,
|
||||
});
|
||||
|
||||
expect(advisorModelSelect).toBeDisabled();
|
||||
expect(advisorModelSelect).toHaveTextContent(/Loading/i);
|
||||
expect(
|
||||
canvas.getByText(/Loading chat model overrides\./i),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ModelConfigsError: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
model_config_id: "model-2",
|
||||
},
|
||||
modelConfigsError: new Error("fail"),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const advisorModelSelect = await canvas.findByRole("combobox", {
|
||||
name: /Advisor model/i,
|
||||
});
|
||||
|
||||
expect(advisorModelSelect).toBeDisabled();
|
||||
expect(
|
||||
canvas.getByText(
|
||||
/Model overrides are unavailable\. The current selection will be sent unchanged\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ModelConfigsErrorWithUnsetSelection: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
},
|
||||
modelConfigsError: new Error("fail"),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(
|
||||
canvas.getByText(
|
||||
/Model overrides are unavailable\. Saving will keep using the chat model\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadError: Story = {
|
||||
args: {
|
||||
advisorConfigData: undefined,
|
||||
isAdvisorConfigLoadError: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(
|
||||
canvas.getByText(/Failed to load advisor settings\./i),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Saving: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
},
|
||||
isSavingAdvisorConfig: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(
|
||||
canvas.getByRole("switch", { name: /Enable advisor/i }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
canvas.getByRole("spinbutton", { name: /Max uses per run/i }),
|
||||
).toBeDisabled();
|
||||
expect(canvas.getByRole("button", { name: /Save/i })).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const SaveError: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
},
|
||||
isSaveAdvisorConfigError: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(
|
||||
canvas.getByText(/Failed to save advisor settings\./i),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SaveErrorWithDetail: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
},
|
||||
isSaveAdvisorConfigError: true,
|
||||
saveAdvisorConfigError: new Error(
|
||||
'model_config_id "missing" does not match any existing model config.',
|
||||
),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(
|
||||
canvas.getByText(
|
||||
/model_config_id "missing" does not match any existing model config\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const DeselectModelBackToUseChatModel: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
model_config_id: "model-2",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(document.body);
|
||||
const advisorModelSelect = await canvas.findByRole("combobox", {
|
||||
name: /Advisor model/i,
|
||||
});
|
||||
|
||||
expect(advisorModelSelect).toHaveTextContent(/Claude Sonnet 4/i);
|
||||
|
||||
await userEvent.click(advisorModelSelect);
|
||||
await userEvent.click(
|
||||
await body.findByRole("option", { name: /^Use chat model$/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advisorModelSelect).toHaveTextContent(/Use chat model/i);
|
||||
});
|
||||
|
||||
const saveButton = canvas.getByRole("button", { name: /Save/i });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAdvisorConfig).toHaveBeenCalled();
|
||||
});
|
||||
const [request] = args.onSaveAdvisorConfig.mock.calls[0];
|
||||
expect(request.model_config_id).toBe(nilUUID);
|
||||
},
|
||||
};
|
||||
|
||||
export const DisableAdvisorWithDeletedModel: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
enabled: true,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "22222222-2222-2222-2222-222222222222",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const enableAdvisorSwitch = await canvas.findByRole("switch", {
|
||||
name: /Enable advisor/i,
|
||||
});
|
||||
|
||||
expect(
|
||||
canvas.getByRole("combobox", { name: /Advisor model/i }),
|
||||
).toHaveTextContent(
|
||||
/Unavailable model \(22222222-2222-2222-2222-222222222222\)/i,
|
||||
);
|
||||
|
||||
await userEvent.click(enableAdvisorSwitch);
|
||||
|
||||
const saveButton = canvas.getByRole("button", { name: /Save/i });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAdvisorConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The backend rejects unknown non-nil model IDs, so disabling must
|
||||
// scrub the stale override rather than forwarding it and failing
|
||||
// the save with a 400.
|
||||
const [request] = args.onSaveAdvisorConfig.mock.calls[0];
|
||||
expect(request).toEqual({
|
||||
enabled: false,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: nilUUID,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DisableAdvisorWhileModelConfigsLoadingPreservesOverride: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
enabled: true,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "22222222-2222-2222-2222-222222222222",
|
||||
},
|
||||
modelConfigs: [],
|
||||
isLoadingModelConfigs: true,
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const enableAdvisorSwitch = await canvas.findByRole("switch", {
|
||||
name: /Enable advisor/i,
|
||||
});
|
||||
|
||||
await userEvent.click(enableAdvisorSwitch);
|
||||
|
||||
const saveButton = canvas.getByRole("button", { name: /Save/i });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAdvisorConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// While the model-configs query is in flight we cannot distinguish a
|
||||
// genuinely deleted override from one we simply have not fetched yet,
|
||||
// so the override must be forwarded unchanged. The backend will
|
||||
// surface a specific 400 if the ID really has been deleted.
|
||||
const [request] = args.onSaveAdvisorConfig.mock.calls[0];
|
||||
expect(request).toEqual({
|
||||
enabled: false,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DisableAdvisorWithModelConfigsErrorPreservesOverride: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
enabled: true,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "22222222-2222-2222-2222-222222222222",
|
||||
},
|
||||
modelConfigs: [],
|
||||
modelConfigsError: new Error("fail"),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const enableAdvisorSwitch = await canvas.findByRole("switch", {
|
||||
name: /Enable advisor/i,
|
||||
});
|
||||
|
||||
await userEvent.click(enableAdvisorSwitch);
|
||||
|
||||
const saveButton = canvas.getByRole("button", { name: /Save/i });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAdvisorConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// When the model-configs query has failed we cannot verify whether
|
||||
// the override still exists, so the override must be forwarded
|
||||
// unchanged rather than silently dropped. The backend surfaces a
|
||||
// specific 400 if the ID really has been deleted.
|
||||
const [request] = args.onSaveAdvisorConfig.mock.calls[0];
|
||||
expect(request).toEqual({
|
||||
enabled: false,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DisableAdvisorWhileModelConfigsRefetchingPreservesOverride: Story =
|
||||
{
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
enabled: true,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "22222222-2222-2222-2222-222222222222",
|
||||
},
|
||||
// Simulate a background refetch with stale cached data: react-query
|
||||
// keeps `isLoading` false once cached data exists, so only
|
||||
// `isFetching` flags the in-flight refetch. The cached list does not
|
||||
// contain the override ID.
|
||||
modelConfigs: mockModelConfigs,
|
||||
isLoadingModelConfigs: false,
|
||||
isFetchingModelConfigs: true,
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const enableAdvisorSwitch = await canvas.findByRole("switch", {
|
||||
name: /Enable advisor/i,
|
||||
});
|
||||
|
||||
await userEvent.click(enableAdvisorSwitch);
|
||||
|
||||
const saveButton = canvas.getByRole("button", { name: /Save/i });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAdvisorConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// While a background refetch is in flight the cached model list may
|
||||
// be stale, so the scrub guard must treat the absence of the
|
||||
// override as indeterminate and forward the ID unchanged. Otherwise
|
||||
// a still-valid override could be silently dropped just because the
|
||||
// cache lags behind the server.
|
||||
const [request] = args.onSaveAdvisorConfig.mock.calls[0];
|
||||
expect(request).toEqual({
|
||||
enabled: false,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DisableAdvisorWithDeletedModelAndEmptyModelConfigs: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
enabled: true,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "22222222-2222-2222-2222-222222222222",
|
||||
},
|
||||
modelConfigs: [],
|
||||
modelConfigsError: undefined,
|
||||
isLoadingModelConfigs: false,
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const enableAdvisorSwitch = await canvas.findByRole("switch", {
|
||||
name: /Enable advisor/i,
|
||||
});
|
||||
|
||||
await userEvent.click(enableAdvisorSwitch);
|
||||
|
||||
const saveButton = canvas.getByRole("button", { name: /Save/i });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAdvisorConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// An empty model-configs list after a successful load is a definitive
|
||||
// answer that the override no longer exists, so disabling must scrub
|
||||
// the stale ID rather than forwarding it and failing the save with a
|
||||
// 400. This covers the recovery case where every model config has
|
||||
// been deleted.
|
||||
const [request] = args.onSaveAdvisorConfig.mock.calls[0];
|
||||
expect(request).toEqual({
|
||||
enabled: false,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: nilUUID,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ValidationBlocksSave: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
...defaultAdvisorConfig,
|
||||
enabled: true,
|
||||
max_uses_per_run: 5,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const maxUsesInput = await canvas.findByRole("spinbutton", {
|
||||
name: /Max uses per run/i,
|
||||
});
|
||||
const saveButton = canvas.getByRole("button", { name: /Save/i });
|
||||
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Dirty-but-invalid state: the field is blank, so client validation
|
||||
// must keep Save disabled even though the form is now dirty.
|
||||
await userEvent.clear(maxUsesInput);
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
await userEvent.type(maxUsesInput, "3");
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DisableThenSave: Story = {
|
||||
args: {
|
||||
advisorConfigData: {
|
||||
enabled: true,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "model-2",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const enableAdvisorSwitch = await canvas.findByRole("switch", {
|
||||
name: /Enable advisor/i,
|
||||
});
|
||||
|
||||
expect(
|
||||
canvas.getByRole("spinbutton", { name: /Max uses per run/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Clear a numeric field to an invalid value before disabling. After
|
||||
// disabling the field is hidden, and saving must not silently overwrite
|
||||
// the stored limit with a coerced value.
|
||||
const maxUsesInput = canvas.getByRole("spinbutton", {
|
||||
name: /Max uses per run/i,
|
||||
});
|
||||
await userEvent.clear(maxUsesInput);
|
||||
|
||||
await userEvent.click(enableAdvisorSwitch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.queryByRole("spinbutton", { name: /Max uses per run/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = canvas.getByRole("button", { name: /Save/i });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAdvisorConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [request] = args.onSaveAdvisorConfig.mock.calls[0];
|
||||
expect(request).toEqual({
|
||||
enabled: false,
|
||||
max_uses_per_run: 5,
|
||||
max_output_tokens: 2048,
|
||||
model_config_id: "model-2",
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,16 +1,12 @@
|
||||
import { useFormik } from "formik";
|
||||
import { TriangleAlertIcon } from "lucide-react";
|
||||
import { type FC, useEffect, useId, useRef } from "react";
|
||||
import { type FC, useId } from "react";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import type {
|
||||
AdvisorConfig,
|
||||
ChatModelConfig,
|
||||
UpdateAdvisorConfigRequest,
|
||||
} from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -18,7 +14,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "#/components/Select/Select";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { useTemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
|
||||
import { AgentSettingLayout } from "#/pages/AISettingsPage/CoderAgentsPage/components/AgentSettingLayout";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
const nilUUID = "00000000-0000-0000-0000-000000000000";
|
||||
const chatModelFallbackValue = "__use-chat-model__";
|
||||
@@ -48,7 +46,6 @@ interface AdvisorSettingsProps {
|
||||
}
|
||||
|
||||
type AdvisorSettingsFormValues = {
|
||||
enabled: boolean;
|
||||
max_uses_per_run: string;
|
||||
max_output_tokens: string;
|
||||
model_config_id: string;
|
||||
@@ -70,7 +67,6 @@ const normalizeNonNegativeInteger = (
|
||||
const normalizeAdvisorConfig = (
|
||||
config: AdvisorConfig | undefined,
|
||||
): AdvisorSettingsFormValues => ({
|
||||
enabled: config?.enabled ?? false,
|
||||
max_uses_per_run: String(
|
||||
normalizeNonNegativeInteger(config?.max_uses_per_run),
|
||||
),
|
||||
@@ -87,7 +83,7 @@ const normalizeAdvisorConfig = (
|
||||
const toAdvisorConfigRequest = (
|
||||
values: AdvisorSettingsFormValues,
|
||||
): UpdateAdvisorConfigRequest => ({
|
||||
enabled: values.enabled,
|
||||
enabled: true,
|
||||
max_uses_per_run: normalizeNonNegativeInteger(values.max_uses_per_run),
|
||||
max_output_tokens: normalizeNonNegativeInteger(values.max_output_tokens),
|
||||
model_config_id: isUnsetModelConfigId(values.model_config_id)
|
||||
@@ -106,13 +102,6 @@ const isNonNegativeIntegerString = (value: string): boolean => {
|
||||
const validateAdvisorConfig = (values: AdvisorSettingsFormValues) => {
|
||||
const errors: Partial<Record<keyof AdvisorSettingsFormValues, string>> = {};
|
||||
|
||||
// Skip validation of the advisor-only fields when the feature is disabled.
|
||||
// Those inputs are hidden, so an admin disabling the advisor should not be
|
||||
// blocked by stale invalid values left in hidden fields.
|
||||
if (!values.enabled) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (!isNonNegativeIntegerString(values.max_uses_per_run)) {
|
||||
errors.max_uses_per_run =
|
||||
"Max uses per run must be a non-negative integer.";
|
||||
@@ -145,54 +134,23 @@ export const AdvisorSettings: FC<AdvisorSettingsProps> = ({
|
||||
}) => {
|
||||
const maxUsesId = useId();
|
||||
const maxOutputTokensId = useId();
|
||||
const { isSavedVisible, showSavedState } = useTemporarySavedState();
|
||||
const hasLoadedAdvisorConfig = advisorConfigData !== undefined;
|
||||
const enabledModelConfigs = modelConfigs.filter((config) => config.enabled);
|
||||
|
||||
// Track the most recent committed advisor values (the server's view or the
|
||||
// last successful save). Reading `advisorConfigData` directly in `onSubmit`
|
||||
// can yield a stale snapshot when a refetch is in flight or has failed,
|
||||
// which would silently roll back recently saved limits if the user then
|
||||
// disables the advisor before the query settles.
|
||||
const committedValuesRef = useRef<AdvisorSettingsFormValues>(
|
||||
normalizeAdvisorConfig(advisorConfigData),
|
||||
);
|
||||
useEffect(() => {
|
||||
committedValuesRef.current = normalizeAdvisorConfig(advisorConfigData);
|
||||
}, [advisorConfigData]);
|
||||
|
||||
const form = useFormik<AdvisorSettingsFormValues>({
|
||||
enableReinitialize: true,
|
||||
validateOnMount: true,
|
||||
initialValues: normalizeAdvisorConfig(advisorConfigData),
|
||||
validate: validateAdvisorConfig,
|
||||
onSubmit: (values, { resetForm }) => {
|
||||
// When disabling, preserve the last committed values for the hidden
|
||||
// fields so potentially invalid in-flight edits (empty strings,
|
||||
// fractional numbers) cannot silently overwrite previously
|
||||
// configured limits, and so a pending or failed refetch of the
|
||||
// advisor config cannot revert recently saved values.
|
||||
let source: AdvisorSettingsFormValues = values.enabled
|
||||
? values
|
||||
: { ...committedValuesRef.current, enabled: false };
|
||||
// If the last committed model override references a model config
|
||||
// that no longer exists, the backend rejects the stale ID with a
|
||||
// 400. When disabling, clear the override so a simple disable
|
||||
// stays reliable in that edge case; the override is unusable
|
||||
// anyway and the admin will reselect one on re-enable. Only scrub
|
||||
// when model configs have loaded successfully and no refetch is in
|
||||
// flight: during an initial load, a background refetch, or on
|
||||
// error we cannot distinguish "truly missing" from "not loaded
|
||||
// yet", and deciding from stale cache could either preserve a
|
||||
// now-deleted ID (causing a 400 on disable/save) or silently drop
|
||||
// an override that is actually still valid but missing from a
|
||||
// stale cache. `isLoading` alone is insufficient because
|
||||
// react-query keeps it false during background refetches when
|
||||
// cached data already exists, so `isFetching` covers that gap. An
|
||||
// empty list after a successful load is a definitive answer, so
|
||||
// the scrub still fires (covers the recovery case where every
|
||||
// model config has been deleted).
|
||||
// 400. Clear the override so a save stays reliable in that edge
|
||||
// case. Only scrub when model configs have loaded successfully and
|
||||
// no refetch is in flight.
|
||||
let source = values;
|
||||
if (
|
||||
!source.enabled &&
|
||||
!isUnsetModelConfigId(source.model_config_id) &&
|
||||
!isLoadingModelConfigs &&
|
||||
!isFetchingModelConfigs &&
|
||||
@@ -205,7 +163,7 @@ export const AdvisorSettings: FC<AdvisorSettingsProps> = ({
|
||||
onSaveAdvisorConfig(request, {
|
||||
onSuccess: () => {
|
||||
const nextValues = normalizeAdvisorConfig(request);
|
||||
committedValuesRef.current = nextValues;
|
||||
showSavedState();
|
||||
resetForm({ values: nextValues });
|
||||
},
|
||||
});
|
||||
@@ -240,188 +198,164 @@ export const AdvisorSettings: FC<AdvisorSettingsProps> = ({
|
||||
: hasUnavailableSelectedModel
|
||||
? unavailableModelValue
|
||||
: form.values.model_config_id;
|
||||
const modelHelperText = isLoadingModelConfigs
|
||||
? "Loading chat model overrides."
|
||||
: modelConfigsError
|
||||
? isUnsetModelConfigId(form.values.model_config_id)
|
||||
? "Model overrides are unavailable. Saving will keep using the chat model."
|
||||
: "Model overrides are unavailable. The current selection will be sent unchanged."
|
||||
: "Choose a dedicated advisor model, or leave this unset to reuse the chat model.";
|
||||
const canSave = hasLoadedAdvisorConfig && form.dirty && form.isValid;
|
||||
|
||||
return (
|
||||
<form className="space-y-3" onSubmit={form.handleSubmit}>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-sm font-semibold text-content-primary">
|
||||
Advisor
|
||||
</h3>
|
||||
<Badge size="sm" variant="warning" className="cursor-default">
|
||||
<TriangleAlertIcon className="size-3" />
|
||||
Experimental feature
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="!mt-0.5 m-0 flex-1 space-y-2 text-xs text-content-secondary">
|
||||
<AgentSettingLayout
|
||||
title="Advisor"
|
||||
description="Cap advisor usage per run and optionally use an override model. The advisor provides strategic guidance to root agent chats. Set limits to 0 for unlimited."
|
||||
showSave={canSave}
|
||||
isSaving={isSavingAdvisorConfig}
|
||||
isSavedVisible={isSavedVisible}
|
||||
saveDisabled={isFormDisabled || !canSave}
|
||||
onSubmit={form.handleSubmit}
|
||||
error={
|
||||
isSaveAdvisorConfigError ? (
|
||||
<p className="m-0">
|
||||
Allow root agent chats to call the advisor tool for strategic
|
||||
guidance.
|
||||
{getErrorMessage(
|
||||
saveAdvisorConfigError,
|
||||
"Failed to save advisor settings.",
|
||||
)}
|
||||
</p>
|
||||
<p className="m-0">
|
||||
When enabled, you can cap advisor usage per run and optionally use
|
||||
an override model.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.values.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
void form.setFieldValue("enabled", checked)
|
||||
) : isAdvisorConfigLoadError ? (
|
||||
<p className="m-0">Failed to load advisor settings.</p>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<CompactIntegerField
|
||||
id={maxUsesId}
|
||||
name="max_uses_per_run"
|
||||
label="Uses / run"
|
||||
ariaLabel="Max uses per run"
|
||||
value={form.values.max_uses_per_run}
|
||||
onChange={(value) => void form.setFieldValue("max_uses_per_run", value)}
|
||||
onBlur={form.handleBlur}
|
||||
error={Boolean(form.errors.max_uses_per_run)}
|
||||
disabled={isFormDisabled}
|
||||
className="w-[7.5rem]"
|
||||
/>
|
||||
<CompactIntegerField
|
||||
id={maxOutputTokensId}
|
||||
name="max_output_tokens"
|
||||
label="Max tokens"
|
||||
ariaLabel="Max output tokens"
|
||||
value={form.values.max_output_tokens}
|
||||
onChange={(value) =>
|
||||
void form.setFieldValue("max_output_tokens", value)
|
||||
}
|
||||
onBlur={form.handleBlur}
|
||||
error={Boolean(form.errors.max_output_tokens)}
|
||||
disabled={isFormDisabled}
|
||||
className="w-36"
|
||||
/>
|
||||
<Select
|
||||
value={selectedModelValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === chatModelFallbackValue) {
|
||||
void form.setFieldValue("model_config_id", "");
|
||||
return;
|
||||
}
|
||||
aria-label="Enable advisor"
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.values.enabled && (
|
||||
<div className="grid gap-4 rounded-lg border border-border bg-surface-secondary p-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={maxUsesId} className="text-xs text-content-primary">
|
||||
Max uses per run
|
||||
</Label>
|
||||
<Input
|
||||
id={maxUsesId}
|
||||
name="max_uses_per_run"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
aria-label="Max uses per run"
|
||||
value={form.values.max_uses_per_run}
|
||||
// Bypass Formik's `handleChange` on purpose: for `type="number"`
|
||||
// it parses the raw input with `parseFloat` and replaces the
|
||||
// declared `string` form value with a `number`, which would
|
||||
// break string-only validators like `isNonNegativeIntegerString`.
|
||||
onChange={(event) =>
|
||||
void form.setFieldValue(
|
||||
"max_uses_per_run",
|
||||
event.currentTarget.value,
|
||||
)
|
||||
}
|
||||
onBlur={form.handleBlur}
|
||||
aria-invalid={Boolean(form.errors.max_uses_per_run)}
|
||||
disabled={isFormDisabled}
|
||||
className="h-9 bg-surface-primary text-[13px]"
|
||||
/>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Set to 0 to leave the per-run call count unlimited.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor={maxOutputTokensId}
|
||||
className="text-xs text-content-primary"
|
||||
>
|
||||
Max output tokens
|
||||
</Label>
|
||||
<Input
|
||||
id={maxOutputTokensId}
|
||||
name="max_output_tokens"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
aria-label="Max output tokens"
|
||||
value={form.values.max_output_tokens}
|
||||
// See `max_uses_per_run` above for why `handleChange` is
|
||||
// bypassed: Formik's `type="number"` coercion would replace
|
||||
// the declared `string` form value with a `number`.
|
||||
onChange={(event) =>
|
||||
void form.setFieldValue(
|
||||
"max_output_tokens",
|
||||
event.currentTarget.value,
|
||||
)
|
||||
}
|
||||
onBlur={form.handleBlur}
|
||||
aria-invalid={Boolean(form.errors.max_output_tokens)}
|
||||
disabled={isFormDisabled}
|
||||
className="h-9 bg-surface-primary text-[13px]"
|
||||
/>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Set to 0 to use the server default output limit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-content-primary">
|
||||
Advisor model
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedModelValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === chatModelFallbackValue) {
|
||||
void form.setFieldValue("model_config_id", "");
|
||||
return;
|
||||
}
|
||||
if (value === unavailableModelValue) {
|
||||
return;
|
||||
}
|
||||
void form.setFieldValue("model_config_id", value);
|
||||
}}
|
||||
disabled={isModelSelectDisabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-9 bg-surface-primary text-[13px]"
|
||||
aria-label="Advisor model"
|
||||
>
|
||||
<SelectValue placeholder="Use chat model">
|
||||
{selectedModelLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hasUnavailableSelectedModel && (
|
||||
<SelectItem value={unavailableModelValue}>
|
||||
{selectedModelLabel}
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value={chatModelFallbackValue}>
|
||||
Use chat model
|
||||
</SelectItem>
|
||||
{enabledModelConfigs.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id}>
|
||||
{getModelDisplayName(config)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
{modelHelperText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isFormDisabled || !form.dirty || !form.isValid}
|
||||
if (value === unavailableModelValue) {
|
||||
return;
|
||||
}
|
||||
void form.setFieldValue("model_config_id", value);
|
||||
}}
|
||||
disabled={isModelSelectDisabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-10 w-[22rem] max-w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-none"
|
||||
aria-label="Advisor model"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isSaveAdvisorConfigError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{getErrorMessage(
|
||||
saveAdvisorConfigError,
|
||||
"Failed to save advisor settings.",
|
||||
<SelectValue placeholder="Use chat model">
|
||||
{selectedModelLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hasUnavailableSelectedModel && (
|
||||
<SelectItem value={unavailableModelValue}>
|
||||
{selectedModelLabel}
|
||||
</SelectItem>
|
||||
)}
|
||||
</p>
|
||||
<SelectItem value={chatModelFallbackValue}>Use chat model</SelectItem>
|
||||
{enabledModelConfigs.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id}>
|
||||
{getModelDisplayName(config)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void form.setValues({
|
||||
max_uses_per_run: "0",
|
||||
max_output_tokens: "0",
|
||||
model_config_id: "",
|
||||
});
|
||||
}}
|
||||
disabled={isFormDisabled}
|
||||
className="h-10"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</AgentSettingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
interface CompactIntegerFieldProps {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
|
||||
error?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CompactIntegerField: FC<CompactIntegerFieldProps> = ({
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
ariaLabel,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
disabled,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-md border border-border border-solid bg-transparent px-3 transition-colors",
|
||||
error && "border-border-destructive",
|
||||
disabled && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
{isAdvisorConfigLoadError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to load advisor settings.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
name={name}
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={error}
|
||||
disabled={disabled}
|
||||
className="min-w-0 w-full border-none bg-transparent p-0 text-sm font-medium leading-6 text-content-placeholder outline-none disabled:cursor-not-allowed [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
/>
|
||||
<span className="shrink-0 text-xs font-normal leading-[18px] text-content-placeholder">
|
||||
{label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
BotIcon,
|
||||
BoxesIcon,
|
||||
CoinsIcon,
|
||||
FlaskConicalIcon,
|
||||
KeyIcon,
|
||||
LayoutTemplateIcon,
|
||||
PanelLeftCloseIcon,
|
||||
@@ -146,8 +145,8 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
|
||||
<SettingsNavItem
|
||||
icon={Settings2Icon}
|
||||
label="Manage agents"
|
||||
active={settingsSection === "experiments"}
|
||||
to="/agents/settings/experiments"
|
||||
active={settingsSection === "coder-agents"}
|
||||
to="/agents/settings/coder-agents"
|
||||
state={location.state}
|
||||
/>
|
||||
)}
|
||||
@@ -203,13 +202,6 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
|
||||
to="/ai/settings/instructions"
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
/>
|
||||
<SettingsNavItem
|
||||
icon={FlaskConicalIcon}
|
||||
label="Experiments"
|
||||
active={settingsSection === "experiments"}
|
||||
to="/agents/settings/experiments"
|
||||
state={location.state}
|
||||
/>
|
||||
<SettingsNavItem
|
||||
icon={RefreshCwIcon}
|
||||
label="Lifecycle"
|
||||
|
||||
@@ -5,10 +5,9 @@ type SidebarView =
|
||||
| { panel: "analytics" };
|
||||
|
||||
const ADMIN_SETTINGS_SECTIONS = new Set([
|
||||
"agents",
|
||||
"coder-agents",
|
||||
"mcp-servers",
|
||||
"spend",
|
||||
"experiments",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TriangleAlertIcon } from "lucide-react";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import {
|
||||
Select,
|
||||
@@ -12,7 +11,8 @@ import {
|
||||
SelectValue,
|
||||
} from "#/components/Select/Select";
|
||||
import { Skeleton } from "#/components/Skeleton/Skeleton";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { useTemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
|
||||
import { AgentSettingLayout } from "#/pages/AISettingsPage/CoderAgentsPage/components/AgentSettingLayout";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
@@ -20,14 +20,6 @@ interface MutationCallbacks {
|
||||
}
|
||||
|
||||
interface VirtualDesktopSettingsProps {
|
||||
desktopEnabledData: TypesGen.ChatDesktopEnabledResponse | undefined;
|
||||
isLoadingDesktopEnabled: boolean;
|
||||
onSaveDesktopEnabled: (
|
||||
req: TypesGen.UpdateChatDesktopEnabledRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingDesktopEnabled: boolean;
|
||||
isSaveDesktopEnabledError: boolean;
|
||||
computerUseProviderData: TypesGen.ChatComputerUseProviderResponse | undefined;
|
||||
isLoadingComputerUseProvider: boolean;
|
||||
onSaveComputerUseProvider: (
|
||||
@@ -51,57 +43,43 @@ const getComputerUseProviderLabel = (provider: string) => {
|
||||
};
|
||||
|
||||
export const VirtualDesktopSettings: FC<VirtualDesktopSettingsProps> = ({
|
||||
desktopEnabledData,
|
||||
isLoadingDesktopEnabled,
|
||||
onSaveDesktopEnabled,
|
||||
isSavingDesktopEnabled,
|
||||
isSaveDesktopEnabledError,
|
||||
computerUseProviderData,
|
||||
isLoadingComputerUseProvider,
|
||||
onSaveComputerUseProvider,
|
||||
isSavingComputerUseProvider,
|
||||
computerUseProviderSaveError,
|
||||
}) => {
|
||||
const desktopEnabled = desktopEnabledData?.enable_desktop ?? false;
|
||||
const computerUseProvider = computerUseProviderData?.provider ?? "";
|
||||
const isDesktopSwitchDisabled =
|
||||
isSavingDesktopEnabled || isLoadingDesktopEnabled;
|
||||
const isComputerUseProviderSelectDisabled =
|
||||
!desktopEnabled ||
|
||||
isSavingDesktopEnabled ||
|
||||
isLoadingDesktopEnabled ||
|
||||
isSavingComputerUseProvider ||
|
||||
isLoadingComputerUseProvider;
|
||||
const { isSavedVisible, showSavedState } = useTemporarySavedState();
|
||||
const serverProvider = computerUseProviderData?.provider ?? "";
|
||||
const hasLoaded = computerUseProviderData !== undefined;
|
||||
|
||||
const form = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
provider: serverProvider,
|
||||
},
|
||||
onSubmit: (values, helpers) => {
|
||||
onSaveComputerUseProvider(
|
||||
{ provider: values.provider },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSavedState();
|
||||
helpers.resetForm({ values });
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const isFormDisabled =
|
||||
isSavingComputerUseProvider || isLoadingComputerUseProvider || !hasLoaded;
|
||||
const canSave = hasLoaded && form.dirty;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-sm font-semibold text-content-primary">
|
||||
Virtual desktop
|
||||
</h3>
|
||||
<Badge size="sm" variant="warning" className="cursor-default">
|
||||
<TriangleAlertIcon className="size-3" />
|
||||
Experimental feature
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoadingDesktopEnabled ? (
|
||||
<Skeleton className="h-5 w-10 rounded-full" aria-hidden="true" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={desktopEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onSaveDesktopEnabled({ enable_desktop: checked })
|
||||
}
|
||||
aria-label="Enable"
|
||||
disabled={isDesktopSwitchDisabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="m-0 flex-1 text-xs text-content-secondary">
|
||||
<p className="m-0">
|
||||
<AgentSettingLayout
|
||||
title="Virtual desktop"
|
||||
description={
|
||||
<>
|
||||
Allow agents to use a virtual, graphical desktop within workspaces.
|
||||
Requires the{" "}
|
||||
<Link
|
||||
@@ -111,41 +89,36 @@ export const VirtualDesktopSettings: FC<VirtualDesktopSettingsProps> = ({
|
||||
>
|
||||
portabledesktop module
|
||||
</Link>{" "}
|
||||
to be installed in the workspace and the selected computer use
|
||||
provider to be configured.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col gap-2 border-0 border-l border-solid border-border pl-4 pt-2 sm:ml-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4
|
||||
id="computer-use-provider-label"
|
||||
className="m-0 text-sm font-medium text-content-primary"
|
||||
>
|
||||
Computer use provider
|
||||
</h4>
|
||||
<p
|
||||
id="computer-use-provider-description"
|
||||
className="m-0 text-xs text-content-secondary"
|
||||
>
|
||||
Select the provider agents use for computer-use actions when virtual
|
||||
desktop is enabled.
|
||||
</p>
|
||||
</div>
|
||||
to be installed in the workspace and a computer use provider to be
|
||||
configured.
|
||||
</>
|
||||
}
|
||||
showSave={canSave}
|
||||
isSaving={isSavingComputerUseProvider}
|
||||
isSavedVisible={isSavedVisible}
|
||||
saveDisabled={isFormDisabled || !canSave}
|
||||
onSubmit={form.handleSubmit}
|
||||
error={
|
||||
computerUseProviderSaveError ? (
|
||||
<p className="m-0">Failed to save computer use provider.</p>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex w-[22rem] max-w-full flex-col gap-2">
|
||||
<Select
|
||||
value={computerUseProvider}
|
||||
onValueChange={(provider) => onSaveComputerUseProvider({ provider })}
|
||||
disabled={isComputerUseProviderSelectDisabled}
|
||||
value={form.values.provider}
|
||||
onValueChange={(value) => void form.setFieldValue("provider", value)}
|
||||
disabled={isFormDisabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-labelledby="computer-use-provider-label"
|
||||
aria-describedby="computer-use-provider-description"
|
||||
className="w-full sm:w-44"
|
||||
aria-label="Computer use provider"
|
||||
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-none"
|
||||
>
|
||||
<SelectValue placeholder="Select provider">
|
||||
{isLoadingComputerUseProvider ? (
|
||||
<Skeleton className="h-4 w-20" aria-hidden="true" />
|
||||
) : computerUseProvider ? (
|
||||
getComputerUseProviderLabel(computerUseProvider)
|
||||
) : form.values.provider ? (
|
||||
getComputerUseProviderLabel(form.values.provider)
|
||||
) : undefined}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
@@ -160,16 +133,6 @@ export const VirtualDesktopSettings: FC<VirtualDesktopSettingsProps> = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isSaveDesktopEnabledError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save desktop setting.
|
||||
</p>
|
||||
)}
|
||||
{computerUseProviderSaveError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save computer use provider.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AgentSettingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
+8
-7
@@ -372,9 +372,6 @@ const AgentSettingsCompactionPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsCompactionPage"),
|
||||
);
|
||||
|
||||
const AgentSettingsExperimentsPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsExperimentsPage"),
|
||||
);
|
||||
const AISettingsLifecyclePage = lazy(
|
||||
() => import("./pages/AISettingsPage/LifecyclePage/LifecyclePage"),
|
||||
);
|
||||
@@ -856,10 +853,6 @@ export const router = createBrowserRouter(
|
||||
path="instructions"
|
||||
element={<Navigate to="/ai/settings/instructions" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="experiments"
|
||||
element={<AgentSettingsExperimentsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="lifecycle"
|
||||
element={<Navigate to="/ai/settings/lifecycle" replace />}
|
||||
@@ -880,6 +873,14 @@ export const router = createBrowserRouter(
|
||||
path="agents"
|
||||
element={<Navigate to="/ai/settings/coder-agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="coder-agents"
|
||||
element={<Navigate to="/ai/settings/coder-agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="experiments"
|
||||
element={<Navigate to="/ai/settings/coder-agents" replace />}
|
||||
/>
|
||||
<Route path="api-keys" element={<AgentSettingsAPIKeysPage />} />
|
||||
<Route
|
||||
path="providers"
|
||||
|
||||
Reference in New Issue
Block a user