From 4f063cdc4768edc238126beedb9adb27fad003ec Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:32:41 +0100 Subject: [PATCH] feat: separate default and additional Coder Agents system prompts (#23616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins can now control whether the built-in Coder Agents default system prompt is prepended to their custom instructions, rather than having the custom prompt silently replace the default. **Changes:** - New `include_default_system_prompt` boolean toggle (defaults to `true` for existing deployments) stored as a site config key — no migration needed. - GET `/api/experimental/chats/config/system-prompt` returns the toggle state, the custom prompt, and a preview of the built-in default. - PUT persists both the toggle and custom prompt atomically in a single transaction. - `resolvedChatSystemPrompt()` composes `[default?, custom?]` joined by `\n\n`, falling back to the built-in default on DB errors. - Settings UI adds a Switch toggle with conditional helper text and a "Preview" button that shows the built-in default prompt via the existing `TextPreviewDialog`. - Comprehensive test coverage: 15 subtests covering toggle behavior, prompt composition matrix, auth boundaries, and integration with chat creation. --- coderd/database/dbauthz/dbauthz.go | 31 ++ coderd/database/dbauthz/dbauthz_test.go | 15 + coderd/database/dbmetrics/querymetrics.go | 24 + coderd/database/dbmock/dbmock.go | 44 ++ coderd/database/querier.go | 12 + coderd/database/queries.sql.go | 77 ++++ coderd/database/queries/siteconfig.sql | 50 +++ coderd/exp_chats.go | 73 +++- coderd/exp_chats_test.go | 411 +++++++++++++++++- codersdk/chats.go | 27 +- site/src/api/api.ts | 15 +- site/src/api/queries/chats.ts | 3 +- site/src/api/typesGenerated.ts | 18 +- .../AgentSettingsPageView.stories.tsx | 81 ++++ .../AgentsPage/AgentSettingsPageView.tsx | 80 +++- .../AgentsPage/AgentsPageView.stories.tsx | 2 + 16 files changed, 895 insertions(+), 68 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3d1c86ebf6..17b6bad1d6 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2578,6 +2578,18 @@ func (q *querier) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]dat return files, nil } +func (q *querier) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) { + // The include-default-system-prompt flag is a deployment-wide setting read + // during chat creation by every authenticated user, so no RBAC policy + // check is needed. We still verify that a valid actor exists in the + // context to ensure this is never callable by an unauthenticated or + // system-internal path without an explicit actor. + if _, ok := ActorFromContext(ctx); !ok { + return false, ErrNoActor + } + return q.db.GetChatIncludeDefaultSystemPrompt(ctx) +} + func (q *querier) GetChatMessageByID(ctx context.Context, id int64) (database.ChatMessage, error) { // ChatMessages are authorized through their parent Chat. // We need to fetch the message first to get its chat_id. @@ -2674,6 +2686,18 @@ func (q *querier) GetChatSystemPrompt(ctx context.Context) (string, error) { return q.db.GetChatSystemPrompt(ctx) } +func (q *querier) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) { + // The system prompt configuration is a deployment-wide setting read during + // chat creation by every authenticated user, so no RBAC policy check is + // needed. We still verify that a valid actor exists in the context to + // ensure this is never callable by an unauthenticated or system-internal + // path without an explicit actor. + if _, ok := ActorFromContext(ctx); !ok { + return database.GetChatSystemPromptConfigRow{}, ErrNoActor + } + return q.db.GetChatSystemPromptConfig(ctx) +} + // GetChatTemplateAllowlist requires deployment-config read permission, // unlike the peer getters (GetChatDesktopEnabled, etc.) which only // check actor presence. The allowlist is admin-configuration that @@ -6842,6 +6866,13 @@ func (q *querier) UpsertChatDiffStatusReference(ctx context.Context, arg databas return q.db.UpsertChatDiffStatusReference(ctx, arg) } +func (q *querier) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefaultSystemPrompt) +} + func (q *querier) UpsertChatSystemPrompt(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 124917f6e1..befc38a551 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -655,6 +655,17 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatQueuedMessages(gomock.Any(), chat.ID).Return(qms, nil).AnyTimes() check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(qms) })) + s.Run("GetChatIncludeDefaultSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetChatIncludeDefaultSystemPrompt(gomock.Any()).Return(true, nil).AnyTimes() + check.Args().Asserts() + })) + s.Run("GetChatSystemPromptConfig", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetChatSystemPromptConfig(gomock.Any()).Return(database.GetChatSystemPromptConfigRow{ + ChatSystemPrompt: "prompt", + IncludeDefaultSystemPrompt: true, + }, nil).AnyTimes() + check.Args().Asserts() + })) s.Run("GetChatSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetChatSystemPrompt(gomock.Any()).Return("prompt", nil).AnyTimes() check.Args().Asserts() @@ -900,6 +911,10 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().BackoffChatDiffStatus(gomock.Any(), arg).Return(nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns() })) + s.Run("UpsertChatIncludeDefaultSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertChatIncludeDefaultSystemPrompt(gomock.Any(), false).Return(nil).AnyTimes() + check.Args(false).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("UpsertChatSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().UpsertChatSystemPrompt(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 7d42128d46..25c2f936c8 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1120,6 +1120,14 @@ func (m queryMetricsStore) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUI return r0, r1 } +func (m queryMetricsStore) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) { + start := time.Now() + r0, r1 := m.s.GetChatIncludeDefaultSystemPrompt(ctx) + m.queryLatencies.WithLabelValues("GetChatIncludeDefaultSystemPrompt").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatIncludeDefaultSystemPrompt").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatMessageByID(ctx context.Context, id int64) (database.ChatMessage, error) { start := time.Now() r0, r1 := m.s.GetChatMessageByID(ctx, id) @@ -1208,6 +1216,14 @@ func (m queryMetricsStore) GetChatSystemPrompt(ctx context.Context) (string, err return r0, r1 } +func (m queryMetricsStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) { + start := time.Now() + r0, r1 := m.s.GetChatSystemPromptConfig(ctx) + m.queryLatencies.WithLabelValues("GetChatSystemPromptConfig").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatSystemPromptConfig").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatTemplateAllowlist(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetChatTemplateAllowlist(ctx) @@ -4840,6 +4856,14 @@ func (m queryMetricsStore) UpsertChatDiffStatusReference(ctx context.Context, ar return r0, r1 } +func (m queryMetricsStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error { + start := time.Now() + r0 := m.s.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefaultSystemPrompt) + m.queryLatencies.WithLabelValues("UpsertChatIncludeDefaultSystemPrompt").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatIncludeDefaultSystemPrompt").Inc() + return r0 +} + func (m queryMetricsStore) UpsertChatSystemPrompt(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertChatSystemPrompt(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e83d12eb58..3a99e3d29e 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2058,6 +2058,21 @@ func (mr *MockStoreMockRecorder) GetChatFilesByIDs(ctx, ids any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatFilesByIDs", reflect.TypeOf((*MockStore)(nil).GetChatFilesByIDs), ctx, ids) } +// GetChatIncludeDefaultSystemPrompt mocks base method. +func (m *MockStore) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatIncludeDefaultSystemPrompt", ctx) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatIncludeDefaultSystemPrompt indicates an expected call of GetChatIncludeDefaultSystemPrompt. +func (mr *MockStoreMockRecorder) GetChatIncludeDefaultSystemPrompt(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatIncludeDefaultSystemPrompt", reflect.TypeOf((*MockStore)(nil).GetChatIncludeDefaultSystemPrompt), ctx) +} + // GetChatMessageByID mocks base method. func (m *MockStore) GetChatMessageByID(ctx context.Context, id int64) (database.ChatMessage, error) { m.ctrl.T.Helper() @@ -2223,6 +2238,21 @@ func (mr *MockStoreMockRecorder) GetChatSystemPrompt(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSystemPrompt", reflect.TypeOf((*MockStore)(nil).GetChatSystemPrompt), ctx) } +// GetChatSystemPromptConfig mocks base method. +func (m *MockStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatSystemPromptConfig", ctx) + ret0, _ := ret[0].(database.GetChatSystemPromptConfigRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatSystemPromptConfig indicates an expected call of GetChatSystemPromptConfig. +func (mr *MockStoreMockRecorder) GetChatSystemPromptConfig(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSystemPromptConfig", reflect.TypeOf((*MockStore)(nil).GetChatSystemPromptConfig), ctx) +} + // GetChatTemplateAllowlist mocks base method. func (m *MockStore) GetChatTemplateAllowlist(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -9074,6 +9104,20 @@ func (mr *MockStoreMockRecorder) UpsertChatDiffStatusReference(ctx, arg any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDiffStatusReference", reflect.TypeOf((*MockStore)(nil).UpsertChatDiffStatusReference), ctx, arg) } +// UpsertChatIncludeDefaultSystemPrompt mocks base method. +func (m *MockStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatIncludeDefaultSystemPrompt", ctx, includeDefaultSystemPrompt) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertChatIncludeDefaultSystemPrompt indicates an expected call of UpsertChatIncludeDefaultSystemPrompt. +func (mr *MockStoreMockRecorder) UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefaultSystemPrompt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatIncludeDefaultSystemPrompt", reflect.TypeOf((*MockStore)(nil).UpsertChatIncludeDefaultSystemPrompt), ctx, includeDefaultSystemPrompt) +} + // UpsertChatSystemPrompt mocks base method. func (m *MockStore) UpsertChatSystemPrompt(ctx context.Context, value string) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 66fc9f2aed..6c74b523d7 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -243,6 +243,11 @@ type sqlcQuerier interface { GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error) GetChatFileByID(ctx context.Context, id uuid.UUID) (ChatFile, error) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]ChatFile, error) + // GetChatIncludeDefaultSystemPrompt preserves the legacy default + // for deployments created before the explicit include-default toggle. + // When the toggle is unset, a non-empty custom prompt implies false; + // otherwise the setting defaults to true. + GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) GetChatMessageByID(ctx context.Context, id int64) (ChatMessage, error) GetChatMessagesByChatID(ctx context.Context, arg GetChatMessagesByChatIDParams) ([]ChatMessage, error) GetChatMessagesByChatIDDescPaginated(ctx context.Context, arg GetChatMessagesByChatIDDescPaginatedParams) ([]ChatMessage, error) @@ -254,6 +259,12 @@ type sqlcQuerier interface { GetChatProviders(ctx context.Context) ([]ChatProvider, error) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) ([]ChatQueuedMessage, error) GetChatSystemPrompt(ctx context.Context) (string, error) + // GetChatSystemPromptConfig returns both chat system prompt settings in a + // single read to avoid torn reads between separate site-config lookups. + // The include-default fallback preserves the legacy behavior where a + // non-empty custom prompt implied opting out before the explicit toggle + // existed. + GetChatSystemPromptConfig(ctx context.Context) (GetChatSystemPromptConfigRow, error) // GetChatTemplateAllowlist returns the JSON-encoded template allowlist. // Returns an empty string when no allowlist has been configured (all templates allowed). GetChatTemplateAllowlist(ctx context.Context) (string, error) @@ -942,6 +953,7 @@ type sqlcQuerier interface { UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error) UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error) + UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error UpsertChatSystemPrompt(ctx context.Context, value string) error UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error UpsertChatUsageLimitConfig(ctx context.Context, arg UpsertChatUsageLimitConfigParams) (ChatUsageLimitConfig, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6960f5172f..1a97955cab 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17736,6 +17736,30 @@ func (q *sqlQuerier) GetChatDesktopEnabled(ctx context.Context) (bool, error) { return enable_desktop, err } +const getChatIncludeDefaultSystemPrompt = `-- name: GetChatIncludeDefaultSystemPrompt :one +SELECT + COALESCE( + (SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_include_default_system_prompt'), + NOT EXISTS ( + SELECT 1 + FROM site_configs + WHERE key = 'agents_chat_system_prompt' + AND value != '' + ) + ) :: boolean AS include_default_system_prompt +` + +// GetChatIncludeDefaultSystemPrompt preserves the legacy default +// for deployments created before the explicit include-default toggle. +// When the toggle is unset, a non-empty custom prompt implies false; +// otherwise the setting defaults to true. +func (q *sqlQuerier) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) { + row := q.db.QueryRowContext(ctx, getChatIncludeDefaultSystemPrompt) + var include_default_system_prompt bool + err := row.Scan(&include_default_system_prompt) + return include_default_system_prompt, err +} + const getChatSystemPrompt = `-- name: GetChatSystemPrompt :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt @@ -17748,6 +17772,37 @@ func (q *sqlQuerier) GetChatSystemPrompt(ctx context.Context) (string, error) { return chat_system_prompt, err } +const getChatSystemPromptConfig = `-- name: GetChatSystemPromptConfig :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt, + COALESCE( + (SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_include_default_system_prompt'), + NOT EXISTS ( + SELECT 1 + FROM site_configs + WHERE key = 'agents_chat_system_prompt' + AND value != '' + ) + ) :: boolean AS include_default_system_prompt +` + +type GetChatSystemPromptConfigRow struct { + ChatSystemPrompt string `db:"chat_system_prompt" json:"chat_system_prompt"` + IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"` +} + +// GetChatSystemPromptConfig returns both chat system prompt settings in a +// single read to avoid torn reads between separate site-config lookups. +// The include-default fallback preserves the legacy behavior where a +// non-empty custom prompt implied opting out before the explicit toggle +// existed. +func (q *sqlQuerier) GetChatSystemPromptConfig(ctx context.Context) (GetChatSystemPromptConfigRow, error) { + row := q.db.QueryRowContext(ctx, getChatSystemPromptConfig) + var i GetChatSystemPromptConfigRow + err := row.Scan(&i.ChatSystemPrompt, &i.IncludeDefaultSystemPrompt) + return i, err +} + const getChatTemplateAllowlist = `-- name: GetChatTemplateAllowlist :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'agents_template_allowlist'), '') :: text AS template_allowlist @@ -17983,6 +18038,28 @@ func (q *sqlQuerier) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop return err } +const upsertChatIncludeDefaultSystemPrompt = `-- name: UpsertChatIncludeDefaultSystemPrompt :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'agents_chat_include_default_system_prompt', + CASE + WHEN $1::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN $1::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'agents_chat_include_default_system_prompt' +` + +func (q *sqlQuerier) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error { + _, err := q.db.ExecContext(ctx, upsertChatIncludeDefaultSystemPrompt, includeDefaultSystemPrompt) + return err +} + const upsertChatSystemPrompt = `-- name: UpsertChatSystemPrompt :exec INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt' diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 96ebfd5f52..3d1fc91686 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -137,6 +137,24 @@ SELECT SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt; +-- GetChatSystemPromptConfig returns both chat system prompt settings in a +-- single read to avoid torn reads between separate site-config lookups. +-- The include-default fallback preserves the legacy behavior where a +-- non-empty custom prompt implied opting out before the explicit toggle +-- existed. +-- name: GetChatSystemPromptConfig :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt, + COALESCE( + (SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_include_default_system_prompt'), + NOT EXISTS ( + SELECT 1 + FROM site_configs + WHERE key = 'agents_chat_system_prompt' + AND value != '' + ) + ) :: boolean AS include_default_system_prompt; + -- name: UpsertChatSystemPrompt :exec INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt'; @@ -167,6 +185,38 @@ WHERE site_configs.key = 'agents_desktop_enabled'; SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'agents_template_allowlist'), '') :: text AS template_allowlist; +-- GetChatIncludeDefaultSystemPrompt preserves the legacy default +-- for deployments created before the explicit include-default toggle. +-- When the toggle is unset, a non-empty custom prompt implies false; +-- otherwise the setting defaults to true. +-- name: GetChatIncludeDefaultSystemPrompt :one +SELECT + COALESCE( + (SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_include_default_system_prompt'), + NOT EXISTS ( + SELECT 1 + FROM site_configs + WHERE key = 'agents_chat_system_prompt' + AND value != '' + ) + ) :: boolean AS include_default_system_prompt; + +-- name: UpsertChatIncludeDefaultSystemPrompt :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'agents_chat_include_default_system_prompt', + CASE + WHEN sqlc.arg(include_default_system_prompt)::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN sqlc.arg(include_default_system_prompt)::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'agents_chat_include_default_system_prompt'; + -- name: GetChatWorkspaceTTL :one -- Returns the global TTL for chat workspaces as a Go duration string. -- Returns "0s" (disabled) when no value has been configured. diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 390b23caf2..4b208c500c 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -2785,25 +2785,35 @@ func detectChatFileType(data []byte) string { //nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - prompt, err := api.Database.GetChatSystemPrompt(ctx) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.ResourceNotFound(rw) + return + } + config, err := api.Database.GetChatSystemPromptConfig(ctx) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching chat system prompt.", + Message: "Internal error fetching chat system prompt configuration.", Detail: err.Error(), }) return } - httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatSystemPrompt{ - SystemPrompt: prompt, + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatSystemPromptResponse{ + SystemPrompt: config.ChatSystemPrompt, + IncludeDefaultSystemPrompt: config.IncludeDefaultSystemPrompt, + DefaultSystemPrompt: chatd.DefaultSystemPrompt, }) } func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } // Cap the raw request body to prevent excessive memory use from // payloads padded with invisible characters that sanitize away. r.Body = http.MaxBytesReader(rw, r.Body, int64(2*maxSystemPromptLenBytes)) - var req codersdk.ChatSystemPrompt + var req codersdk.UpdateChatSystemPromptRequest if !httpapi.Read(ctx, rw, r, &req) { return } @@ -2817,13 +2827,23 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { }) return } - err := api.Database.UpsertChatSystemPrompt(ctx, sanitizedPrompt) - if httpapi.Is404Error(err) { // also catches authz error - httpapi.ResourceNotFound(rw) - return - } else if err != nil { + err := api.Database.InTx(func(tx database.Store) error { + if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil { + return err + } + // Only update the include-default flag when the caller explicitly + // provides it. Omitting the field preserves whatever is currently + // stored (or the schema-level default for new deployments), + // avoiding a backward-compatibility regression for older clients + // that only send system_prompt. + if req.IncludeDefaultSystemPrompt != nil { + return tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt) + } + return nil + }, nil) + if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating chat system prompt.", + Message: "Internal error updating chat system prompt configuration.", Detail: err.Error(), }) return @@ -3329,21 +3349,32 @@ func (api *API) deleteUserChatCompactionThreshold(rw http.ResponseWriter, r *htt } func (api *API) resolvedChatSystemPrompt(ctx context.Context) string { - custom, err := api.Database.GetChatSystemPrompt(ctx) + config, err := api.Database.GetChatSystemPromptConfig(ctx) if err != nil { - // Log but don't fail chat creation — fall back to the - // built-in default so the user isn't blocked. - api.Logger.Error(ctx, "failed to fetch custom chat system prompt, using default", slog.Error(err)) + // We intentionally fail open here. When the prompt configuration + // cannot be read, returning the built-in default keeps the chat + // grounded instead of sending no system guidance at all. + api.Logger.Error(ctx, "failed to fetch chat system prompt configuration, using default", slog.Error(err)) return chatd.DefaultSystemPrompt } - sanitized := chatd.SanitizePromptText(custom) - if sanitized == "" && strings.TrimSpace(custom) != "" { - api.Logger.Warn(ctx, "custom system prompt became empty after sanitization, using default") + + sanitizedCustom := chatd.SanitizePromptText(config.ChatSystemPrompt) + if sanitizedCustom == "" && strings.TrimSpace(config.ChatSystemPrompt) != "" { + api.Logger.Warn(ctx, "custom system prompt became empty after sanitization, omitting custom portion") } - if sanitized != "" { - return sanitized + + var parts []string + if config.IncludeDefaultSystemPrompt { + parts = append(parts, chatd.DefaultSystemPrompt) } - return chatd.DefaultSystemPrompt + if sanitizedCustom != "" { + parts = append(parts, sanitizedCustom) + } + result := strings.Join(parts, "\n\n") + if result == "" { + api.Logger.Warn(ctx, "resolved system prompt is empty, no system prompt will be injected into chats") + } + return result } func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 4737972216..264fcc9648 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -5,12 +5,14 @@ import ( "context" "database/sql" "encoding/json" + stderrors "errors" "fmt" "mime" "net/http" "net/http/httptest" "regexp" "strings" + "sync/atomic" "testing" "time" @@ -25,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/externalauth" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/rbac" @@ -63,6 +66,35 @@ func newChatClientWithDatabase(t testing.TB) (*codersdk.ExperimentalClient, data return codersdk.NewExperimentalClient(client), db } +type failNextChatSystemPromptStore struct { + database.Store + + failNextGetChatIncludeDefaultSystemPrompt atomic.Bool + failNextGetChatSystemPromptConfig atomic.Bool + failNextUpsertChatIncludeDefaultSystemPrompt atomic.Bool +} + +func (s *failNextChatSystemPromptStore) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) { + if s.failNextGetChatIncludeDefaultSystemPrompt.CompareAndSwap(true, false) { + return false, stderrors.New("forced include-default read failure") + } + return s.Store.GetChatIncludeDefaultSystemPrompt(ctx) +} + +func (s *failNextChatSystemPromptStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefault bool) error { + if s.failNextUpsertChatIncludeDefaultSystemPrompt.CompareAndSwap(true, false) { + return stderrors.New("forced include-default upsert failure") + } + return s.Store.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefault) +} + +func (s *failNextChatSystemPromptStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) { + if s.failNextGetChatSystemPromptConfig.CompareAndSwap(true, false) { + return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced chat system prompt configuration read failure") + } + return s.Store.GetChatSystemPromptConfig(ctx) +} + func requireChatUsageLimitExceededError( t *testing.T, err error, @@ -4750,52 +4782,398 @@ func createChatModelConfig(t *testing.T, client *codersdk.ExperimentalClient) co func TestChatSystemPrompt(t *testing.T) { t.Parallel() - adminClient := newChatClient(t) + adminClient, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + _ = createChatModelConfig(t, adminClient) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) + const workspaceAwareness = "There is no workspace associated with this chat yet. Create one using the create_workspace tool before using workspace tools like execute, read_file, write_file, etc." + + updateChatSystemPrompt := func(t *testing.T, ctx context.Context, req codersdk.UpdateChatSystemPromptRequest) { + t.Helper() + + err := adminClient.UpdateChatSystemPrompt(ctx, req) + require.NoError(t, err) + } + + getChatSystemPrompt := func(t *testing.T, ctx context.Context) codersdk.ChatSystemPromptResponse { + t.Helper() + + resp, err := adminClient.GetChatSystemPrompt(ctx) + require.NoError(t, err) + return resp + } + + assertInjectedSystemMessages := func(t *testing.T, ctx context.Context, wantResolvedPrompt string) { + t.Helper() + + chat, err := adminClient.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: fmt.Sprintf("system prompt composition %s", t.Name()), + }, + }, + }) + require.NoError(t, err) + + messages, err := db.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + + var systemTexts []string + for _, message := range messages { + if message.Role != database.ChatMessageRoleSystem { + continue + } + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + require.Len(t, parts, 1) + require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type) + systemTexts = append(systemTexts, parts[0].Text) + } + + if wantResolvedPrompt == "" { + require.Equal(t, []string{workspaceAwareness}, systemTexts) + return + } + + require.Equal(t, []string{wantResolvedPrompt, workspaceAwareness}, systemTexts) + } + t.Run("ReturnsEmptyWhenUnset", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - resp, err := adminClient.GetChatSystemPrompt(ctx) - require.NoError(t, err) + resp := getChatSystemPrompt(t, ctx) require.Equal(t, "", resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt, "should default to true") + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt, "should return the built-in default prompt for preview") }) t.Run("AdminCanSet", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.ChatSystemPrompt{ - SystemPrompt: "You are a helpful coding assistant.", + updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "You are a helpful coding assistant.", + IncludeDefaultSystemPrompt: ptr.Ref(true), }) - require.NoError(t, err) - resp, err := adminClient.GetChatSystemPrompt(ctx) - require.NoError(t, err) + resp := getChatSystemPrompt(t, ctx) require.Equal(t, "You are a helpful coding assistant.", resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) }) t.Run("AdminCanUnset", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) // Unset by sending an empty string. - err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.ChatSystemPrompt{ - SystemPrompt: "", + updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + + resp := getChatSystemPrompt(t, ctx) + require.Empty(t, resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) + }) + + t.Run("ToggleIncludeDefault", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + IncludeDefaultSystemPrompt: ptr.Ref(false), + }) + + resp := getChatSystemPrompt(t, ctx) + require.Empty(t, resp.SystemPrompt) + require.False(t, resp.IncludeDefaultSystemPrompt) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) + + updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + + resp = getChatSystemPrompt(t, ctx) + require.Empty(t, resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) + }) + + t.Run("PreservesIncludeDefaultWhenOmitted", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := &failNextChatSystemPromptStore{Store: rawDB} + client := codersdk.NewExperimentalClient(coderdtest.New(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: chatDeploymentValues(t), + })) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + IncludeDefaultSystemPrompt: ptr.Ref(false), }) require.NoError(t, err) - resp, err := adminClient.GetChatSystemPrompt(ctx) + store.failNextGetChatIncludeDefaultSystemPrompt.Store(true) + store.failNextUpsertChatIncludeDefaultSystemPrompt.Store(true) + + err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Omitted toggle request", + }) require.NoError(t, err) - require.Equal(t, "", resp.SystemPrompt) + + resp, err := client.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "Omitted toggle request", resp.SystemPrompt) + require.False(t, resp.IncludeDefaultSystemPrompt) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) + }) + + t.Run("ExistingCustomPromptDefaultsIncludeDefaultOff", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + legacyClient, legacyDB := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, legacyClient.Client) + _ = createChatModelConfig(t, legacyClient) + + require.NoError(t, legacyDB.UpsertChatSystemPrompt(dbauthz.AsSystemRestricted(ctx), "Legacy custom instructions")) + + resp, err := legacyClient.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "Legacy custom instructions", resp.SystemPrompt) + require.False(t, resp.IncludeDefaultSystemPrompt) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) + + chat, err := legacyClient.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: fmt.Sprintf("legacy custom prompt %s", t.Name()), + }}, + }) + require.NoError(t, err) + + messages, err := legacyDB.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + + var systemTexts []string + for _, message := range messages { + if message.Role != database.ChatMessageRoleSystem { + continue + } + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + require.Len(t, parts, 1) + require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type) + systemTexts = append(systemTexts, parts[0].Text) + } + + require.Equal(t, []string{"Legacy custom instructions", workspaceAwareness}, systemTexts) + }) + + t.Run("DefaultSystemPromptPreview", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + resp := getChatSystemPrompt(t, ctx) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) + require.NotEmpty(t, resp.DefaultSystemPrompt, "built-in default prompt should not be empty") + }) + + t.Run("SavesBothFieldsTogether", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Custom instructions for all users.", + IncludeDefaultSystemPrompt: ptr.Ref(false), + }) + + resp := getChatSystemPrompt(t, ctx) + require.Equal(t, "Custom instructions for all users.", resp.SystemPrompt) + require.False(t, resp.IncludeDefaultSystemPrompt) + + updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Different instructions.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + + resp = getChatSystemPrompt(t, ctx) + require.Equal(t, "Different instructions.", resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) + }) + + t.Run("PromptComposition", func(t *testing.T) { + t.Run("DefaultOnlyWhenToggleOnAndEmpty", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + + resp := getChatSystemPrompt(t, ctx) + require.Empty(t, resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) + assertInjectedSystemMessages(t, ctx, chatd.DefaultSystemPrompt) + }) + + t.Run("BothWhenToggleOnAndNonEmpty", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Custom instructions", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + + resp := getChatSystemPrompt(t, ctx) + require.Equal(t, "Custom instructions", resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) + assertInjectedSystemMessages(t, ctx, chatd.DefaultSystemPrompt+"\n\nCustom instructions") + }) + + t.Run("CustomOnlyWhenToggleOff", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Custom only", + IncludeDefaultSystemPrompt: ptr.Ref(false), + }) + + resp := getChatSystemPrompt(t, ctx) + require.Equal(t, "Custom only", resp.SystemPrompt) + require.False(t, resp.IncludeDefaultSystemPrompt) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) + assertInjectedSystemMessages(t, ctx, "Custom only") + }) + + t.Run("EmptyWhenToggleOffAndEmpty", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + IncludeDefaultSystemPrompt: ptr.Ref(false), + }) + + resp := getChatSystemPrompt(t, ctx) + require.Empty(t, resp.SystemPrompt) + require.False(t, resp.IncludeDefaultSystemPrompt) + require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt) + assertInjectedSystemMessages(t, ctx, "") + }) + }) + + t.Run("CreateChatFallsBackToDefaultWhenSystemPromptConfigReadFailsWithIncludeDefaultEnabled", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := &failNextChatSystemPromptStore{Store: rawDB} + client := codersdk.NewExperimentalClient(coderdtest.New(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: chatDeploymentValues(t), + })) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Keep custom instructions", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + + store.failNextGetChatSystemPromptConfig.Store(true) + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: fmt.Sprintf("config-read fallback %s", t.Name()), + }}, + }) + require.NoError(t, err) + + messages, err := rawDB.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + + var systemTexts []string + for _, message := range messages { + if message.Role != database.ChatMessageRoleSystem { + continue + } + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + require.Len(t, parts, 1) + require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type) + systemTexts = append(systemTexts, parts[0].Text) + } + + require.Equal(t, []string{chatd.DefaultSystemPrompt, workspaceAwareness}, systemTexts) + }) + + t.Run("CreateChatFallbackIgnoresDisabledPreferenceWhenConfigReadFails", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := &failNextChatSystemPromptStore{Store: rawDB} + client := codersdk.NewExperimentalClient(coderdtest.New(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: chatDeploymentValues(t), + })) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Do not use the default prompt", + IncludeDefaultSystemPrompt: ptr.Ref(false), + }) + require.NoError(t, err) + + // A config read failure loses all admin preferences, including + // include_default=false, so chat creation falls back to the built-in default. + store.failNextGetChatSystemPromptConfig.Store(true) + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: fmt.Sprintf("config-read fallback %s", t.Name()), + }}, + }) + require.NoError(t, err) + + messages, err := rawDB.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + + var systemTexts []string + for _, message := range messages { + if message.Role != database.ChatMessageRoleSystem { + continue + } + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + require.Len(t, parts, 1) + require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type) + systemTexts = append(systemTexts, parts[0].Text) + } + + require.Equal(t, []string{chatd.DefaultSystemPrompt, workspaceAwareness}, systemTexts) }) t.Run("NonAdminFails", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - err := memberClient.UpdateChatSystemPrompt(ctx, codersdk.ChatSystemPrompt{ - SystemPrompt: "This should fail.", + err := memberClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "This should fail.", + IncludeDefaultSystemPrompt: ptr.Ref(true), }) + requireSDKError(t, err, http.StatusForbidden) + + _, err = memberClient.GetChatSystemPrompt(ctx) requireSDKError(t, err, http.StatusNotFound) }) @@ -4814,8 +5192,9 @@ func TestChatSystemPrompt(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) tooLong := strings.Repeat("a", 131073) - err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.ChatSystemPrompt{ - SystemPrompt: tooLong, + err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: tooLong, + IncludeDefaultSystemPrompt: ptr.Ref(true), }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "System prompt exceeds maximum length.", sdkErr.Message) diff --git a/codersdk/chats.go b/codersdk/chats.go index 0fd5ba8107..0fb34e2838 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -388,10 +388,19 @@ type ChatModelsResponse struct { Providers []ChatModelProvider `json:"providers"` } -// ChatSystemPrompt is the request and response body for the chat -// system prompt configuration endpoint. -type ChatSystemPrompt struct { - SystemPrompt string `json:"system_prompt"` +// ChatSystemPromptResponse is the response body for the chat system prompt +// configuration endpoint. +type ChatSystemPromptResponse struct { + SystemPrompt string `json:"system_prompt"` + IncludeDefaultSystemPrompt bool `json:"include_default_system_prompt"` + DefaultSystemPrompt string `json:"default_system_prompt"` +} + +// UpdateChatSystemPromptRequest is the request body for updating the chat +// system prompt configuration. +type UpdateChatSystemPromptRequest struct { + SystemPrompt string `json:"system_prompt"` + IncludeDefaultSystemPrompt *bool `json:"include_default_system_prompt,omitempty"` } // UserChatCustomPrompt is the request and response body for the @@ -1407,21 +1416,21 @@ func (c *ExperimentalClient) GetChatCostUsers(ctx context.Context, opts ChatCost } // GetChatSystemPrompt returns the deployment-wide chat system prompt. -func (c *ExperimentalClient) GetChatSystemPrompt(ctx context.Context) (ChatSystemPrompt, error) { +func (c *ExperimentalClient) GetChatSystemPrompt(ctx context.Context) (ChatSystemPromptResponse, error) { res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/system-prompt", nil) if err != nil { - return ChatSystemPrompt{}, err + return ChatSystemPromptResponse{}, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return ChatSystemPrompt{}, ReadBodyAsError(res) + return ChatSystemPromptResponse{}, ReadBodyAsError(res) } - var resp ChatSystemPrompt + var resp ChatSystemPromptResponse return resp, json.NewDecoder(res.Body).Decode(&resp) } // UpdateChatSystemPrompt updates the deployment-wide chat system prompt. -func (c *ExperimentalClient) UpdateChatSystemPrompt(ctx context.Context, req ChatSystemPrompt) error { +func (c *ExperimentalClient) UpdateChatSystemPrompt(ctx context.Context, req UpdateChatSystemPromptRequest) error { res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/system-prompt", req) if err != nil { return err diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a2683db1aa..609ceb1d5e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3189,15 +3189,16 @@ class ExperimentalApiMethods { return response.data; }; - getChatSystemPrompt = async (): Promise => { - const response = await this.axios.get( - "/api/experimental/chats/config/system-prompt", - ); - return response.data; - }; + getChatSystemPrompt = + async (): Promise => { + const response = await this.axios.get( + "/api/experimental/chats/config/system-prompt", + ); + return response.data; + }; updateChatSystemPrompt = async ( - req: TypesGen.ChatSystemPrompt, + req: TypesGen.UpdateChatSystemPromptRequest, ): Promise => { await this.axios.put("/api/experimental/chats/config/system-prompt", req); }; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index e501881406..b9502a4838 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -419,7 +419,8 @@ export const chatSystemPrompt = () => ({ }); export const updateChatSystemPrompt = (queryClient: QueryClient) => ({ - mutationFn: API.experimental.updateChatSystemPrompt, + mutationFn: (req: TypesGen.UpdateChatSystemPromptRequest) => + API.experimental.updateChatSystemPrompt(req), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: chatSystemPromptKey, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7488acef07..70548639be 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1901,11 +1901,13 @@ export interface ChatStreamStatus { // From codersdk/chats.go /** - * ChatSystemPrompt is the request and response body for the chat - * system prompt configuration endpoint. + * ChatSystemPromptResponse is the response body for the chat system prompt + * configuration endpoint. */ -export interface ChatSystemPrompt { +export interface ChatSystemPromptResponse { readonly system_prompt: string; + readonly include_default_system_prompt: boolean; + readonly default_system_prompt: string; } // From codersdk/chats.go @@ -7021,6 +7023,16 @@ export interface UpdateChatRequest { readonly labels?: Record; } +// From codersdk/chats.go +/** + * UpdateChatSystemPromptRequest is the request body for updating the chat + * system prompt configuration. + */ +export interface UpdateChatSystemPromptRequest { + readonly system_prompt: string; + readonly include_default_system_prompt?: boolean; +} + // From codersdk/chats.go /** * UpdateChatUsageLimitGroupOverrideRequest is kept as a compatibility alias. diff --git a/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx index ec2f9205e8..9a28a103fb 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPageView.stories.tsx @@ -128,6 +128,7 @@ const getChatCostUsersCalls = () => ).mock.calls; const fixedNow = dayjs("2026-03-12T00:00:00Z"); +const mockDefaultSystemPrompt = "You are Coder, an AI coding assistant..."; // ── Meta ─────────────────────────────────────────────────────── @@ -148,6 +149,8 @@ const meta = { beforeEach: () => { spyOn(API.experimental, "getChatSystemPrompt").mockResolvedValue({ system_prompt: "", + include_default_system_prompt: true, + default_system_prompt: mockDefaultSystemPrompt, }); spyOn(API.experimental, "updateChatSystemPrompt").mockResolvedValue(); spyOn(API.experimental, "getChatDesktopEnabled").mockResolvedValue({ @@ -231,6 +234,79 @@ export const TogglesDesktop: Story = { }, }; +export const AdminWithDefaultToggleOn: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChatSystemPrompt").mockResolvedValue({ + system_prompt: "Always use TypeScript for code examples.", + include_default_system_prompt: true, + default_system_prompt: mockDefaultSystemPrompt, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + const toggle = await canvas.findByRole("switch", { + name: "Include Coder Agents default system prompt", + }); + expect(toggle).toBeChecked(); + expect( + await canvas.findByDisplayValue( + "Always use TypeScript for code examples.", + ), + ).toBeInTheDocument(); + expect( + canvas.getByText(/built-in Coder Agents prompt is prepended/i), + ).toBeInTheDocument(); + + await userEvent.click(canvas.getByRole("button", { name: "Preview" })); + expect(await body.findByText("Default System Prompt")).toBeInTheDocument(); + expect(body.getByText(mockDefaultSystemPrompt)).toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + await waitFor(() => { + expect(body.queryByText("Default System Prompt")).not.toBeInTheDocument(); + }); + + await userEvent.click(toggle); + const promptForm = canvas + .getByDisplayValue("Always use TypeScript for code examples.") + .closest("form")!; + const saveButton = within(promptForm).getByRole("button", { name: "Save" }); + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); + await userEvent.click(saveButton); + await waitFor(() => { + expect(API.experimental.updateChatSystemPrompt).toHaveBeenCalledWith({ + system_prompt: "Always use TypeScript for code examples.", + include_default_system_prompt: false, + }); + }); + }, +}; + +export const AdminWithDefaultToggleOff: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChatSystemPrompt").mockResolvedValue({ + system_prompt: "You are a custom assistant.", + include_default_system_prompt: false, + default_system_prompt: mockDefaultSystemPrompt, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Include Coder Agents default system prompt", + }); + expect(toggle).not.toBeChecked(); + expect( + await canvas.findByDisplayValue("You are a custom assistant."), + ).toBeInTheDocument(); + expect( + canvas.getByText(/only the additional instructions below are used/i), + ).toBeInTheDocument(); + }, +}; + export const DefaultAutostopDefault: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -538,6 +614,7 @@ export const DefaultAutostopNotVisibleToNonAdmin: Story = { const desktopHeading = canvas.queryByText("Virtual Desktop"); expect(desktopHeading).toBeNull(); + expect(API.experimental.getChatSystemPrompt).not.toHaveBeenCalled(); }, }; @@ -786,6 +863,8 @@ export const InvisibleUnicodeWarningSystemPrompt: Story = { spyOn(API.experimental, "getChatSystemPrompt").mockResolvedValue({ system_prompt: "Normal prompt text\u200b\u200b\u200b\u200bhidden instruction", + include_default_system_prompt: true, + default_system_prompt: mockDefaultSystemPrompt, }); }, play: async ({ canvasElement }) => { @@ -846,6 +925,8 @@ export const NoWarningForCleanPrompt: Story = { beforeEach: () => { spyOn(API.experimental, "getChatSystemPrompt").mockResolvedValue({ system_prompt: "You are a helpful coding assistant.", + include_default_system_prompt: true, + default_system_prompt: mockDefaultSystemPrompt, }); spyOn(API.experimental, "getUserChatCustomPrompt").mockResolvedValue({ custom_prompt: "Be concise and use TypeScript.", diff --git a/site/src/pages/AgentsPage/AgentSettingsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsPageView.tsx index 2d18196531..3357751f58 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPageView.tsx @@ -72,6 +72,7 @@ import { InsightsContent } from "./components/InsightsContent"; import { LimitsTab } from "./components/LimitsTab"; import { MCPServerAdminPanel } from "./components/MCPServerAdminPanel"; import { SectionHeader } from "./components/SectionHeader"; +import { TextPreviewDialog } from "./components/TextPreviewDialog"; import { UserCompactionThresholdSettings } from "./UserCompactionThresholdSettings"; const AdminBadge: FC = () => ( @@ -520,7 +521,10 @@ export const AgentSettingsPageView: FC = ({ }) => { const queryClient = useQueryClient(); - const systemPromptQuery = useQuery(chatSystemPrompt()); + const systemPromptQuery = useQuery({ + ...chatSystemPrompt(), + enabled: canSetSystemPrompt, + }); const { mutate: saveSystemPrompt, isPending: isSavingSystemPrompt, @@ -552,9 +556,21 @@ export const AgentSettingsPageView: FC = ({ isError: isSaveWorkspaceTTLError, } = useMutation(updateChatWorkspaceTTL(queryClient)); + const hasLoadedSystemPrompt = systemPromptQuery.isSuccess; const serverPrompt = systemPromptQuery.data?.system_prompt ?? ""; + const serverIncludeDefault = + systemPromptQuery.data?.include_default_system_prompt; + const defaultSystemPrompt = + systemPromptQuery.data?.default_system_prompt ?? ""; const [localEdit, setLocalEdit] = useState(null); + const [localIncludeDefault, setLocalIncludeDefault] = useState< + boolean | null + >(null); + const [showDefaultPromptPreview, setShowDefaultPromptPreview] = + useState(false); const systemPromptDraft = localEdit ?? serverPrompt; + const includeDefaultDraft = + localIncludeDefault ?? serverIncludeDefault ?? false; const serverUserPrompt = userPromptQuery.data?.custom_prompt ?? ""; const [localUserEdit, setLocalUserEdit] = useState(null); @@ -572,7 +588,11 @@ export const AgentSettingsPageView: FC = ({ const [isUserPromptOverflowing, setIsUserPromptOverflowing] = useState(false); const [isSystemPromptOverflowing, setIsSystemPromptOverflowing] = useState(false); - const isSystemPromptDirty = localEdit !== null && localEdit !== serverPrompt; + const isSystemPromptDirty = + hasLoadedSystemPrompt && + ((localEdit !== null && localEdit !== serverPrompt) || + (localIncludeDefault !== null && + localIncludeDefault !== serverIncludeDefault)); const isUserPromptDirty = localUserEdit !== null && localUserEdit !== serverUserPrompt; const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false; @@ -586,16 +606,25 @@ export const AgentSettingsPageView: FC = ({ const isTTLOverMax = ttlMs > maxTTLMs; const isTTLZero = isAutostopEnabled && ttlMs === 0; const isPromptSaving = isSavingSystemPrompt || isSavingUserPrompt; + const isSystemPromptDisabled = isPromptSaving || !hasLoadedSystemPrompt; const isDesktopSaving = isSavingDesktopEnabled; const isTTLSaving = isSavingWorkspaceTTL; const isTTLLoading = workspaceTTLQuery.isLoading; const handleSaveSystemPrompt = (event: FormEvent) => { event.preventDefault(); - if (!isSystemPromptDirty) return; + if (!hasLoadedSystemPrompt || !isSystemPromptDirty) return; saveSystemPrompt( - { system_prompt: systemPromptDraft }, - { onSuccess: () => setLocalEdit(null) }, + { + system_prompt: systemPromptDraft, + include_default_system_prompt: includeDefaultDraft, + }, + { + onSuccess: () => { + setLocalEdit(null); + setLocalIncludeDefault(null); + }, + }, ); }; @@ -732,22 +761,44 @@ export const AgentSettingsPageView: FC = ({ +
+
+ Include Coder Agents default system prompt + +
+ +

- Applied to all chats for every user. When empty, the - built-in default is used. + {includeDefaultDraft + ? "The built-in Coder Agents prompt is prepended. Additional instructions below are appended." + : "Only the additional instructions below are used. When empty, no deployment-wide system prompt is sent."}

setLocalEdit(event.target.value)} onHeightChange={(height) => setIsSystemPromptOverflowing(height >= textareaMaxHeight) } - disabled={isPromptSaving} + disabled={isSystemPromptDisabled} minRows={1} /> {systemInvisibleCharCount > 0 && ( @@ -766,14 +817,14 @@ export const AgentSettingsPageView: FC = ({ variant="outline" type="button" onClick={() => setLocalEdit("")} - disabled={isPromptSaving || !systemPromptDraft} + disabled={isSystemPromptDisabled || !systemPromptDraft} > Clear @@ -942,6 +993,13 @@ export const AgentSettingsPageView: FC = ({ )} + {showDefaultPromptPreview && ( + setShowDefaultPromptPreview(false)} + /> + )} ); }; diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index ef3ee1830b..e0a77b6ed9 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -202,6 +202,8 @@ const meta: Meta = { ); spyOn(API.experimental, "getChatSystemPrompt").mockResolvedValue({ system_prompt: "", + include_default_system_prompt: true, + default_system_prompt: "You are Coder, an AI coding assistant...", }); spyOn(API.experimental, "updateChatSystemPrompt").mockResolvedValue(); spyOn(API.experimental, "getUserChatCustomPrompt").mockResolvedValue({