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)
|
||||
|
||||
+18
-9
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+15
-3
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user