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)