mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user