feat: separate default and additional Coder Agents system prompts (#23616)

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.
This commit is contained in:
Michael Suchacz
2026-03-26 13:32:41 +01:00
committed by GitHub
parent d175e799da
commit 4f063cdc47
16 changed files with 895 additions and 68 deletions
+31
View File
@@ -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
+15
View File
@@ -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)
+24
View File
@@ -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)
+44
View File
@@ -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()
+12
View File
@@ -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)
+77
View File
@@ -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'
+50
View File
@@ -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.
+52 -21
View File
@@ -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) {
+395 -16
View File
@@ -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)
+18 -9
View File
@@ -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
+8 -7
View File
@@ -3189,15 +3189,16 @@ class ExperimentalApiMethods {
return response.data;
};
getChatSystemPrompt = async (): Promise<TypesGen.ChatSystemPrompt> => {
const response = await this.axios.get<TypesGen.ChatSystemPrompt>(
"/api/experimental/chats/config/system-prompt",
);
return response.data;
};
getChatSystemPrompt =
async (): Promise<TypesGen.ChatSystemPromptResponse> => {
const response = await this.axios.get<TypesGen.ChatSystemPromptResponse>(
"/api/experimental/chats/config/system-prompt",
);
return response.data;
};
updateChatSystemPrompt = async (
req: TypesGen.ChatSystemPrompt,
req: TypesGen.UpdateChatSystemPromptRequest,
): Promise<void> => {
await this.axios.put("/api/experimental/chats/config/system-prompt", req);
};
+2 -1
View File
@@ -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,
+15 -3
View File
@@ -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<string, string>;
}
// 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.
@@ -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.",
@@ -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<AgentSettingsPageViewProps> = ({
}) => {
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<AgentSettingsPageViewProps> = ({
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<string | null>(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<string | null>(null);
@@ -572,7 +588,11 @@ export const AgentSettingsPageView: FC<AgentSettingsPageViewProps> = ({
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<AgentSettingsPageViewProps> = ({
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<AgentSettingsPageViewProps> = ({
</h3>
<AdminBadge />
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-content-primary">
<span>Include Coder Agents default system prompt</span>
<Button
size="xs"
variant="subtle"
type="button"
onClick={() => setShowDefaultPromptPreview(true)}
disabled={!hasLoadedSystemPrompt}
className="min-w-0 px-0 text-content-link hover:text-content-link"
>
Preview
</Button>
</div>
<Switch
checked={includeDefaultDraft}
onCheckedChange={setLocalIncludeDefault}
aria-label="Include Coder Agents default system prompt"
disabled={isSystemPromptDisabled}
/>
</div>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
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."}
</p>
<TextareaAutosize
className={cn(
textareaBaseClassName,
isSystemPromptOverflowing && textareaOverflowClassName,
)}
placeholder="Additional behavior, style, and tone preferences for all users"
placeholder="Additional instructions for all users"
value={systemPromptDraft}
onChange={(event) => 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<AgentSettingsPageViewProps> = ({
variant="outline"
type="button"
onClick={() => setLocalEdit("")}
disabled={isPromptSaving || !systemPromptDraft}
disabled={isSystemPromptDisabled || !systemPromptDraft}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isPromptSaving || !isSystemPromptDirty}
disabled={isSystemPromptDisabled || !isSystemPromptDirty}
>
Save
</Button>
@@ -942,6 +993,13 @@ export const AgentSettingsPageView: FC<AgentSettingsPageViewProps> = ({
<TemplateAllowlistSection />
)}
</div>
{showDefaultPromptPreview && (
<TextPreviewDialog
content={defaultSystemPrompt}
fileName="Default System Prompt"
onClose={() => setShowDefaultPromptPreview(false)}
/>
)}
</div>
);
};
@@ -202,6 +202,8 @@ const meta: Meta<typeof AgentsPageView> = {
);
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({