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

Admins can now control whether the built-in Coder Agents default system
prompt is prepended to their custom instructions, rather than having the
custom prompt silently replace the default.

**Changes:**
- New `include_default_system_prompt` boolean toggle (defaults to `true`
for existing deployments) stored as a site config key — no migration
needed.
- GET `/api/experimental/chats/config/system-prompt` returns the toggle
state, the custom prompt, and a preview of the built-in default.
- PUT persists both the toggle and custom prompt atomically in a single
transaction.
- `resolvedChatSystemPrompt()` composes `[default?, custom?]` joined by
`\n\n`, falling back to the built-in default on DB errors.
- Settings UI adds a Switch toggle with conditional helper text and a
"Preview" button that shows the built-in default prompt via the existing
`TextPreviewDialog`.
- Comprehensive test coverage: 15 subtests covering toggle behavior,
prompt composition matrix, auth boundaries, and integration with chat
creation.
This commit is contained in:
Michael Suchacz
2026-03-26 13:32:41 +01:00
committed by GitHub
parent d175e799da
commit 4f063cdc47
16 changed files with 895 additions and 68 deletions
+395 -16
View File
@@ -5,12 +5,14 @@ import (
"context"
"database/sql"
"encoding/json"
stderrors "errors"
"fmt"
"mime"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync/atomic"
"testing"
"time"
@@ -25,6 +27,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/externalauth"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
@@ -63,6 +66,35 @@ func newChatClientWithDatabase(t testing.TB) (*codersdk.ExperimentalClient, data
return codersdk.NewExperimentalClient(client), db
}
type failNextChatSystemPromptStore struct {
database.Store
failNextGetChatIncludeDefaultSystemPrompt atomic.Bool
failNextGetChatSystemPromptConfig atomic.Bool
failNextUpsertChatIncludeDefaultSystemPrompt atomic.Bool
}
func (s *failNextChatSystemPromptStore) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) {
if s.failNextGetChatIncludeDefaultSystemPrompt.CompareAndSwap(true, false) {
return false, stderrors.New("forced include-default read failure")
}
return s.Store.GetChatIncludeDefaultSystemPrompt(ctx)
}
func (s *failNextChatSystemPromptStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefault bool) error {
if s.failNextUpsertChatIncludeDefaultSystemPrompt.CompareAndSwap(true, false) {
return stderrors.New("forced include-default upsert failure")
}
return s.Store.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefault)
}
func (s *failNextChatSystemPromptStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) {
if s.failNextGetChatSystemPromptConfig.CompareAndSwap(true, false) {
return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced chat system prompt configuration read failure")
}
return s.Store.GetChatSystemPromptConfig(ctx)
}
func requireChatUsageLimitExceededError(
t *testing.T,
err error,
@@ -4750,52 +4782,398 @@ func createChatModelConfig(t *testing.T, client *codersdk.ExperimentalClient) co
func TestChatSystemPrompt(t *testing.T) {
t.Parallel()
adminClient := newChatClient(t)
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
_ = createChatModelConfig(t, adminClient)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
const workspaceAwareness = "There is no workspace associated with this chat yet. Create one using the create_workspace tool before using workspace tools like execute, read_file, write_file, etc."
updateChatSystemPrompt := func(t *testing.T, ctx context.Context, req codersdk.UpdateChatSystemPromptRequest) {
t.Helper()
err := adminClient.UpdateChatSystemPrompt(ctx, req)
require.NoError(t, err)
}
getChatSystemPrompt := func(t *testing.T, ctx context.Context) codersdk.ChatSystemPromptResponse {
t.Helper()
resp, err := adminClient.GetChatSystemPrompt(ctx)
require.NoError(t, err)
return resp
}
assertInjectedSystemMessages := func(t *testing.T, ctx context.Context, wantResolvedPrompt string) {
t.Helper()
chat, err := adminClient.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: fmt.Sprintf("system prompt composition %s", t.Name()),
},
},
})
require.NoError(t, err)
messages, err := db.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
var systemTexts []string
for _, message := range messages {
if message.Role != database.ChatMessageRoleSystem {
continue
}
parts, err := chatprompt.ParseContent(message)
require.NoError(t, err)
require.Len(t, parts, 1)
require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type)
systemTexts = append(systemTexts, parts[0].Text)
}
if wantResolvedPrompt == "" {
require.Equal(t, []string{workspaceAwareness}, systemTexts)
return
}
require.Equal(t, []string{wantResolvedPrompt, workspaceAwareness}, systemTexts)
}
t.Run("ReturnsEmptyWhenUnset", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := adminClient.GetChatSystemPrompt(ctx)
require.NoError(t, err)
resp := getChatSystemPrompt(t, ctx)
require.Equal(t, "", resp.SystemPrompt)
require.True(t, resp.IncludeDefaultSystemPrompt, "should default to true")
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt, "should return the built-in default prompt for preview")
})
t.Run("AdminCanSet", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.ChatSystemPrompt{
SystemPrompt: "You are a helpful coding assistant.",
updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "You are a helpful coding assistant.",
IncludeDefaultSystemPrompt: ptr.Ref(true),
})
require.NoError(t, err)
resp, err := adminClient.GetChatSystemPrompt(ctx)
require.NoError(t, err)
resp := getChatSystemPrompt(t, ctx)
require.Equal(t, "You are a helpful coding assistant.", resp.SystemPrompt)
require.True(t, resp.IncludeDefaultSystemPrompt)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
})
t.Run("AdminCanUnset", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
// Unset by sending an empty string.
err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.ChatSystemPrompt{
SystemPrompt: "",
updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "",
IncludeDefaultSystemPrompt: ptr.Ref(true),
})
resp := getChatSystemPrompt(t, ctx)
require.Empty(t, resp.SystemPrompt)
require.True(t, resp.IncludeDefaultSystemPrompt)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
})
t.Run("ToggleIncludeDefault", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "",
IncludeDefaultSystemPrompt: ptr.Ref(false),
})
resp := getChatSystemPrompt(t, ctx)
require.Empty(t, resp.SystemPrompt)
require.False(t, resp.IncludeDefaultSystemPrompt)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "",
IncludeDefaultSystemPrompt: ptr.Ref(true),
})
resp = getChatSystemPrompt(t, ctx)
require.Empty(t, resp.SystemPrompt)
require.True(t, resp.IncludeDefaultSystemPrompt)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
})
t.Run("PreservesIncludeDefaultWhenOmitted", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
store := &failNextChatSystemPromptStore{Store: rawDB}
client := codersdk.NewExperimentalClient(coderdtest.New(t, &coderdtest.Options{
Database: store,
Pubsub: pubsub,
DeploymentValues: chatDeploymentValues(t),
}))
_ = coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "",
IncludeDefaultSystemPrompt: ptr.Ref(false),
})
require.NoError(t, err)
resp, err := adminClient.GetChatSystemPrompt(ctx)
store.failNextGetChatIncludeDefaultSystemPrompt.Store(true)
store.failNextUpsertChatIncludeDefaultSystemPrompt.Store(true)
err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "Omitted toggle request",
})
require.NoError(t, err)
require.Equal(t, "", resp.SystemPrompt)
resp, err := client.GetChatSystemPrompt(ctx)
require.NoError(t, err)
require.Equal(t, "Omitted toggle request", resp.SystemPrompt)
require.False(t, resp.IncludeDefaultSystemPrompt)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
})
t.Run("ExistingCustomPromptDefaultsIncludeDefaultOff", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
legacyClient, legacyDB := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, legacyClient.Client)
_ = createChatModelConfig(t, legacyClient)
require.NoError(t, legacyDB.UpsertChatSystemPrompt(dbauthz.AsSystemRestricted(ctx), "Legacy custom instructions"))
resp, err := legacyClient.GetChatSystemPrompt(ctx)
require.NoError(t, err)
require.Equal(t, "Legacy custom instructions", resp.SystemPrompt)
require.False(t, resp.IncludeDefaultSystemPrompt)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
chat, err := legacyClient.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: fmt.Sprintf("legacy custom prompt %s", t.Name()),
}},
})
require.NoError(t, err)
messages, err := legacyDB.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
var systemTexts []string
for _, message := range messages {
if message.Role != database.ChatMessageRoleSystem {
continue
}
parts, err := chatprompt.ParseContent(message)
require.NoError(t, err)
require.Len(t, parts, 1)
require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type)
systemTexts = append(systemTexts, parts[0].Text)
}
require.Equal(t, []string{"Legacy custom instructions", workspaceAwareness}, systemTexts)
})
t.Run("DefaultSystemPromptPreview", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
resp := getChatSystemPrompt(t, ctx)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
require.NotEmpty(t, resp.DefaultSystemPrompt, "built-in default prompt should not be empty")
})
t.Run("SavesBothFieldsTogether", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "Custom instructions for all users.",
IncludeDefaultSystemPrompt: ptr.Ref(false),
})
resp := getChatSystemPrompt(t, ctx)
require.Equal(t, "Custom instructions for all users.", resp.SystemPrompt)
require.False(t, resp.IncludeDefaultSystemPrompt)
updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "Different instructions.",
IncludeDefaultSystemPrompt: ptr.Ref(true),
})
resp = getChatSystemPrompt(t, ctx)
require.Equal(t, "Different instructions.", resp.SystemPrompt)
require.True(t, resp.IncludeDefaultSystemPrompt)
})
t.Run("PromptComposition", func(t *testing.T) {
t.Run("DefaultOnlyWhenToggleOnAndEmpty", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "",
IncludeDefaultSystemPrompt: ptr.Ref(true),
})
resp := getChatSystemPrompt(t, ctx)
require.Empty(t, resp.SystemPrompt)
require.True(t, resp.IncludeDefaultSystemPrompt)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
assertInjectedSystemMessages(t, ctx, chatd.DefaultSystemPrompt)
})
t.Run("BothWhenToggleOnAndNonEmpty", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "Custom instructions",
IncludeDefaultSystemPrompt: ptr.Ref(true),
})
resp := getChatSystemPrompt(t, ctx)
require.Equal(t, "Custom instructions", resp.SystemPrompt)
require.True(t, resp.IncludeDefaultSystemPrompt)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
assertInjectedSystemMessages(t, ctx, chatd.DefaultSystemPrompt+"\n\nCustom instructions")
})
t.Run("CustomOnlyWhenToggleOff", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "Custom only",
IncludeDefaultSystemPrompt: ptr.Ref(false),
})
resp := getChatSystemPrompt(t, ctx)
require.Equal(t, "Custom only", resp.SystemPrompt)
require.False(t, resp.IncludeDefaultSystemPrompt)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
assertInjectedSystemMessages(t, ctx, "Custom only")
})
t.Run("EmptyWhenToggleOffAndEmpty", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
updateChatSystemPrompt(t, ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "",
IncludeDefaultSystemPrompt: ptr.Ref(false),
})
resp := getChatSystemPrompt(t, ctx)
require.Empty(t, resp.SystemPrompt)
require.False(t, resp.IncludeDefaultSystemPrompt)
require.Equal(t, chatd.DefaultSystemPrompt, resp.DefaultSystemPrompt)
assertInjectedSystemMessages(t, ctx, "")
})
})
t.Run("CreateChatFallsBackToDefaultWhenSystemPromptConfigReadFailsWithIncludeDefaultEnabled", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
store := &failNextChatSystemPromptStore{Store: rawDB}
client := codersdk.NewExperimentalClient(coderdtest.New(t, &coderdtest.Options{
Database: store,
Pubsub: pubsub,
DeploymentValues: chatDeploymentValues(t),
}))
_ = coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "Keep custom instructions",
IncludeDefaultSystemPrompt: ptr.Ref(true),
})
require.NoError(t, err)
store.failNextGetChatSystemPromptConfig.Store(true)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: fmt.Sprintf("config-read fallback %s", t.Name()),
}},
})
require.NoError(t, err)
messages, err := rawDB.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
var systemTexts []string
for _, message := range messages {
if message.Role != database.ChatMessageRoleSystem {
continue
}
parts, err := chatprompt.ParseContent(message)
require.NoError(t, err)
require.Len(t, parts, 1)
require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type)
systemTexts = append(systemTexts, parts[0].Text)
}
require.Equal(t, []string{chatd.DefaultSystemPrompt, workspaceAwareness}, systemTexts)
})
t.Run("CreateChatFallbackIgnoresDisabledPreferenceWhenConfigReadFails", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
store := &failNextChatSystemPromptStore{Store: rawDB}
client := codersdk.NewExperimentalClient(coderdtest.New(t, &coderdtest.Options{
Database: store,
Pubsub: pubsub,
DeploymentValues: chatDeploymentValues(t),
}))
_ = coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "Do not use the default prompt",
IncludeDefaultSystemPrompt: ptr.Ref(false),
})
require.NoError(t, err)
// A config read failure loses all admin preferences, including
// include_default=false, so chat creation falls back to the built-in default.
store.failNextGetChatSystemPromptConfig.Store(true)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: fmt.Sprintf("config-read fallback %s", t.Name()),
}},
})
require.NoError(t, err)
messages, err := rawDB.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
var systemTexts []string
for _, message := range messages {
if message.Role != database.ChatMessageRoleSystem {
continue
}
parts, err := chatprompt.ParseContent(message)
require.NoError(t, err)
require.Len(t, parts, 1)
require.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type)
systemTexts = append(systemTexts, parts[0].Text)
}
require.Equal(t, []string{chatd.DefaultSystemPrompt, workspaceAwareness}, systemTexts)
})
t.Run("NonAdminFails", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := memberClient.UpdateChatSystemPrompt(ctx, codersdk.ChatSystemPrompt{
SystemPrompt: "This should fail.",
err := memberClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "This should fail.",
IncludeDefaultSystemPrompt: ptr.Ref(true),
})
requireSDKError(t, err, http.StatusForbidden)
_, err = memberClient.GetChatSystemPrompt(ctx)
requireSDKError(t, err, http.StatusNotFound)
})
@@ -4814,8 +5192,9 @@ func TestChatSystemPrompt(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
tooLong := strings.Repeat("a", 131073)
err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.ChatSystemPrompt{
SystemPrompt: tooLong,
err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: tooLong,
IncludeDefaultSystemPrompt: ptr.Ref(true),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "System prompt exceeds maximum length.", sdkErr.Message)