fix(agents): persist system prompt server-side instead of localStorage (#22857)

## Problem

The Admin → Agents → System Prompt textarea saved only to the browser's
`localStorage`. The value was never sent to the backend, never stored in
the database, and never injected into chats. Entering text, clicking
Save, and refreshing the page showed no changes — the prompt was
effectively a no-op.

## Root Cause

Three disconnected layers:
1. **Frontend** wrote to `localStorage`, never called an API.
2. **`handleCreateChat`** never read `savedSystemPrompt`.
3. **Backend** hardcoded `chatd.DefaultSystemPrompt` on every chat
creation — no field in `CreateChatRequest` accepted a custom prompt.

## Changes

### Database
- Added `GetChatSystemPrompt` / `UpsertChatSystemPrompt` queries on the
existing `site_configs` table (no migration needed).

### API
- `GET /api/experimental/chats/system-prompt` — returns the configured
prompt (any authenticated user).
- `PUT /api/experimental/chats/system-prompt` — sets the prompt
(admin-only, `rbac: deployment_config update`).
- Input validation: max 32 KiB prompt length.

### Backend
- `resolvedChatSystemPrompt(ctx)` checks for a custom prompt in the DB,
falls back to `chatd.DefaultSystemPrompt` when empty/unset.
- Logs a warning on DB errors instead of silently swallowing them.
- Replaced the hardcoded `defaultChatSystemPrompt()` call in chat
creation.

### Frontend
- Replaced `localStorage` read/write with React Query
`useQuery`/`useMutation` backed by the new endpoints.
- Fixed `useEffect` draft sync to avoid clobbering in-progress user
edits on refetch.
- Added `try/catch` error handling on save (draft stays dirty for
retry).
- Save button disabled during mutation (`isSavingSystemPrompt`).
- Query key follows kebab-case convention (`chat-system-prompt`).

### UX
- Added hint: "When empty, the built-in default prompt is used."

### Tests
- `TestChatSystemPrompt`: GET returns empty when unset, admin can set,
non-admin gets 403.
- dbauthz `TestMethodTestSuite` coverage for both new querier methods.
This commit is contained in:
Cian Johnston
2026-03-10 11:46:53 +00:00
committed by GitHub
parent a21f00d250
commit c933ddcffd
18 changed files with 365 additions and 32 deletions
+59 -2
View File
@@ -55,6 +55,7 @@ const (
defaultChatContextCompressionThreshold = int32(70)
minChatContextCompressionThreshold = int32(0)
maxChatContextCompressionThreshold = int32(100)
maxSystemPromptLenBytes = 131072 // 128 KiB
)
// chatDiffRefreshBackoffSchedule defines the delays between successive
@@ -284,7 +285,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
WorkspaceID: workspaceSelection.WorkspaceID,
Title: title,
ModelConfigID: modelConfigID,
SystemPrompt: defaultChatSystemPrompt(),
SystemPrompt: api.resolvedChatSystemPrompt(ctx),
InitialUserContent: contentBlocks,
ContentFileIDs: contentFileIDs,
})
@@ -2262,7 +2263,63 @@ func detectChatFileType(data []byte) string {
return http.DetectContentType(data)
}
func defaultChatSystemPrompt() 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 err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat system prompt.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatSystemPromptResponse{
SystemPrompt: prompt,
})
}
func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req codersdk.UpdateChatSystemPromptRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
trimmedPrompt := strings.TrimSpace(req.SystemPrompt)
// 128 KiB is generous for a system prompt while still
// preventing abuse or accidental pastes of large content.
if len(trimmedPrompt) > maxSystemPromptLenBytes {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "System prompt exceeds maximum length.",
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", maxSystemPromptLenBytes, len(trimmedPrompt)),
})
return
}
err := api.Database.UpsertChatSystemPrompt(ctx, trimmedPrompt)
if httpapi.Is404Error(err) { // also catches authz error
httpapi.ResourceNotFound(rw)
return
} else if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat system prompt.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
func (api *API) resolvedChatSystemPrompt(ctx context.Context) string {
custom, err := api.Database.GetChatSystemPrompt(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))
return chatd.DefaultSystemPrompt
}
if strings.TrimSpace(custom) != "" {
return custom
}
return chatd.DefaultSystemPrompt
}
+75
View File
@@ -3118,6 +3118,81 @@ func createChatModelConfig(t *testing.T, client *codersdk.Client) codersdk.ChatM
return modelConfig
}
//nolint:tparallel,paralleltest // Subtests share a single coderdtest instance.
func TestChatSystemPrompt(t *testing.T) {
t.Parallel()
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
t.Run("ReturnsEmptyWhenUnset", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := adminClient.GetChatSystemPrompt(ctx)
require.NoError(t, err)
require.Equal(t, "", resp.SystemPrompt)
})
t.Run("AdminCanSet", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "You are a helpful coding assistant.",
})
require.NoError(t, err)
resp, err := adminClient.GetChatSystemPrompt(ctx)
require.NoError(t, err)
require.Equal(t, "You are a helpful coding assistant.", resp.SystemPrompt)
})
t.Run("AdminCanUnset", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
// Unset by sending an empty string.
err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "",
})
require.NoError(t, err)
resp, err := adminClient.GetChatSystemPrompt(ctx)
require.NoError(t, err)
require.Equal(t, "", resp.SystemPrompt)
})
t.Run("NonAdminFails", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := memberClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "This should fail.",
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("UnauthenticatedFails", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
anonClient := codersdk.New(adminClient.URL)
_, err := anonClient.GetChatSystemPrompt(ctx)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
})
t.Run("TooLong", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
tooLong := strings.Repeat("a", 131073)
err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: tooLong,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "System prompt exceeds maximum length.", sdkErr.Message)
})
}
func requireSDKError(t *testing.T, err error, expectedStatus int) *codersdk.Error {
t.Helper()
+6
View File
@@ -1127,6 +1127,11 @@ func New(options *Options) *API {
r.Post("/", api.postChatFile)
r.Get("/{file}", api.chatFileByID)
})
r.Route("/config", func(r chi.Router) {
r.Get("/system-prompt", api.getChatSystemPrompt)
r.Put("/system-prompt", api.putChatSystemPrompt)
})
// TODO(cian): place under /api/experimental/chats/config
r.Route("/providers", func(r chi.Router) {
r.Get("/", api.listChatProviders)
r.Post("/", api.createChatProvider)
@@ -1135,6 +1140,7 @@ func New(options *Options) *API {
r.Delete("/", api.deleteChatProvider)
})
})
// TODO(cian): place under /api/experimental/chats/config
r.Route("/model-configs", func(r chi.Router) {
r.Get("/", api.listChatModelConfigs)
r.Post("/", api.createChatModelConfig)
+19
View File
@@ -2564,6 +2564,18 @@ func (q *querier) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) (
return q.db.GetChatQueuedMessages(ctx, chatID)
}
func (q *querier) GetChatSystemPrompt(ctx context.Context) (string, error) {
// The system prompt 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 "", ErrNoActor
}
return q.db.GetChatSystemPrompt(ctx)
}
func (q *querier) GetChatsByOwnerID(ctx context.Context, ownerID database.GetChatsByOwnerIDParams) ([]database.Chat, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByOwnerID)(ctx, ownerID)
}
@@ -6536,6 +6548,13 @@ func (q *querier) UpsertChatDiffStatusReference(ctx context.Context, arg databas
return q.db.UpsertChatDiffStatusReference(ctx, arg)
}
func (q *querier) UpsertChatSystemPrompt(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
}
return q.db.UpsertChatSystemPrompt(ctx, value)
}
func (q *querier) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil {
return database.ConnectionLog{}, err
+8
View File
@@ -551,6 +551,10 @@ 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("GetChatSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatSystemPrompt(gomock.Any()).Return("prompt", nil).AnyTimes()
check.Args().Asserts()
}))
s.Run("GetEnabledChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
configB := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
@@ -758,6 +762,10 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpsertChatDiffStatusReference(gomock.Any(), arg).Return(diffStatus, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(diffStatus)
}))
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)
}))
}
func (s *MethodTestSuite) TestFile() {
+16
View File
@@ -1103,6 +1103,14 @@ func (m queryMetricsStore) GetChatQueuedMessages(ctx context.Context, chatID uui
return r0, r1
}
func (m queryMetricsStore) GetChatSystemPrompt(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetChatSystemPrompt(ctx)
m.queryLatencies.WithLabelValues("GetChatSystemPrompt").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatSystemPrompt").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatsByOwnerID(ctx context.Context, ownerID database.GetChatsByOwnerIDParams) ([]database.Chat, error) {
start := time.Now()
r0, r1 := m.s.GetChatsByOwnerID(ctx, ownerID)
@@ -4526,6 +4534,14 @@ func (m queryMetricsStore) UpsertChatDiffStatusReference(ctx context.Context, ar
return r0, r1
}
func (m queryMetricsStore) UpsertChatSystemPrompt(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertChatSystemPrompt(ctx, value)
m.queryLatencies.WithLabelValues("UpsertChatSystemPrompt").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatSystemPrompt").Inc()
return r0
}
func (m queryMetricsStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
start := time.Now()
r0, r1 := m.s.UpsertConnectionLog(ctx, arg)
+29
View File
@@ -2017,6 +2017,21 @@ func (mr *MockStoreMockRecorder) GetChatQueuedMessages(ctx, chatID any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatQueuedMessages", reflect.TypeOf((*MockStore)(nil).GetChatQueuedMessages), ctx, chatID)
}
// GetChatSystemPrompt mocks base method.
func (m *MockStore) GetChatSystemPrompt(ctx context.Context) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatSystemPrompt", ctx)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatSystemPrompt indicates an expected call of GetChatSystemPrompt.
func (mr *MockStoreMockRecorder) GetChatSystemPrompt(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSystemPrompt", reflect.TypeOf((*MockStore)(nil).GetChatSystemPrompt), ctx)
}
// GetChatsByOwnerID mocks base method.
func (m *MockStore) GetChatsByOwnerID(ctx context.Context, arg database.GetChatsByOwnerIDParams) ([]database.Chat, error) {
m.ctrl.T.Helper()
@@ -8463,6 +8478,20 @@ func (mr *MockStoreMockRecorder) UpsertChatDiffStatusReference(ctx, arg any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDiffStatusReference", reflect.TypeOf((*MockStore)(nil).UpsertChatDiffStatusReference), ctx, arg)
}
// UpsertChatSystemPrompt mocks base method.
func (m *MockStore) UpsertChatSystemPrompt(ctx context.Context, value string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertChatSystemPrompt", ctx, value)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertChatSystemPrompt indicates an expected call of UpsertChatSystemPrompt.
func (mr *MockStoreMockRecorder) UpsertChatSystemPrompt(ctx, value any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatSystemPrompt", reflect.TypeOf((*MockStore)(nil).UpsertChatSystemPrompt), ctx, value)
}
// UpsertConnectionLog mocks base method.
func (m *MockStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
m.ctrl.T.Helper()
+2
View File
@@ -230,6 +230,7 @@ type sqlcQuerier interface {
GetChatProviderByProvider(ctx context.Context, provider string) (ChatProvider, error)
GetChatProviders(ctx context.Context) ([]ChatProvider, error)
GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) ([]ChatQueuedMessage, error)
GetChatSystemPrompt(ctx context.Context) (string, error)
GetChatsByOwnerID(ctx context.Context, arg GetChatsByOwnerIDParams) ([]Chat, error)
GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error)
GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error)
@@ -840,6 +841,7 @@ type sqlcQuerier interface {
UpsertBoundaryUsageStats(ctx context.Context, arg UpsertBoundaryUsageStatsParams) (bool, error)
UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error)
UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error)
UpsertChatSystemPrompt(ctx context.Context, value string) error
UpsertConnectionLog(ctx context.Context, arg UpsertConnectionLogParams) (ConnectionLog, error)
UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error
// The default proxy is implied and not actually stored in the database.
+22
View File
@@ -14475,6 +14475,18 @@ func (q *sqlQuerier) GetApplicationName(ctx context.Context) (string, error) {
return value, err
}
const getChatSystemPrompt = `-- name: GetChatSystemPrompt :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt
`
func (q *sqlQuerier) GetChatSystemPrompt(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getChatSystemPrompt)
var chat_system_prompt string
err := row.Scan(&chat_system_prompt)
return chat_system_prompt, err
}
const getCoordinatorResumeTokenSigningKey = `-- name: GetCoordinatorResumeTokenSigningKey :one
SELECT value FROM site_configs WHERE key = 'coordinator_resume_token_signing_key'
`
@@ -14689,6 +14701,16 @@ func (q *sqlQuerier) UpsertApplicationName(ctx context.Context, value string) er
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'
`
func (q *sqlQuerier) UpsertChatSystemPrompt(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, upsertChatSystemPrompt, value)
return err
}
const upsertCoordinatorResumeTokenSigningKey = `-- name: UpsertCoordinatorResumeTokenSigningKey :exec
INSERT INTO site_configs (key, value) VALUES ('coordinator_resume_token_signing_key', $1)
ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'coordinator_resume_token_signing_key'
+8
View File
@@ -153,3 +153,11 @@ DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key;
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_public_key'), '') :: text AS vapid_public_key,
COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_private_key'), '') :: text AS vapid_private_key;
-- name: GetChatSystemPrompt :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_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';
+37
View File
@@ -202,6 +202,16 @@ type ChatModelsResponse struct {
Providers []ChatModelProvider `json:"providers"`
}
// ChatSystemPromptResponse is the response for getting the chat system prompt.
type ChatSystemPromptResponse struct {
SystemPrompt string `json:"system_prompt"`
}
// UpdateChatSystemPromptRequest is the request to update the chat system prompt.
type UpdateChatSystemPromptRequest struct {
SystemPrompt string `json:"system_prompt"`
}
// ChatProviderConfigSource describes how a provider entry is sourced.
type ChatProviderConfigSource string
@@ -681,6 +691,33 @@ func (c *Client) DeleteChatModelConfig(ctx context.Context, modelConfigID uuid.U
return nil
}
// GetChatSystemPrompt returns the deployment-wide chat system prompt.
func (c *Client) GetChatSystemPrompt(ctx context.Context) (ChatSystemPromptResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/system-prompt", nil)
if err != nil {
return ChatSystemPromptResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatSystemPromptResponse{}, ReadBodyAsError(res)
}
var resp ChatSystemPromptResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateChatSystemPrompt updates the deployment-wide chat system prompt.
func (c *Client) 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
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// CreateChat creates a new chat.
func (c *Client) CreateChat(ctx context.Context, req CreateChatRequest) (Chat, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/experimental/chats", req)
+14
View File
@@ -3052,6 +3052,20 @@ class ApiMethods {
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.UpdateChatSystemPromptRequest,
): Promise<void> => {
await this.axios.put("/api/experimental/chats/config/system-prompt", req);
};
getChatProviderConfigs = async (): Promise<TypesGen.ChatProviderConfig[]> => {
const response = await this.axios.get<TypesGen.ChatProviderConfig[]>(
chatProviderConfigsPath,
+16
View File
@@ -196,6 +196,22 @@ export const chatDiffContents = (chatId: string) => ({
queryFn: () => API.getChatDiffContents(chatId),
});
const chatSystemPromptKey = ["chat-system-prompt"] as const;
export const chatSystemPrompt = () => ({
queryKey: chatSystemPromptKey,
queryFn: () => API.getChatSystemPrompt(),
});
export const updateChatSystemPrompt = (queryClient: QueryClient) => ({
mutationFn: API.updateChatSystemPrompt,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatSystemPromptKey,
});
},
});
export const chatModelsKey = ["chat-models"] as const;
export const chatModels = () => ({
+16
View File
@@ -1611,6 +1611,14 @@ export interface ChatStreamStatus {
readonly status: ChatStatus;
}
// From codersdk/chats.go
/**
* ChatSystemPromptResponse is the response for getting the chat system prompt.
*/
export interface ChatSystemPromptResponse {
readonly system_prompt: string;
}
// From codersdk/chats.go
/**
* ChatWithMessages is a chat along with its messages.
@@ -6303,6 +6311,14 @@ export interface UpdateChatRequest {
readonly title: string;
}
// From codersdk/chats.go
/**
* UpdateChatSystemPromptRequest is the request to update the chat system prompt.
*/
export interface UpdateChatSystemPromptRequest {
readonly system_prompt: string;
}
// From codersdk/updatecheck.go
/**
* UpdateCheckResponse contains information on the latest release of Coder.
@@ -22,8 +22,6 @@ const modelOptions = [
},
] as const;
const behaviorStorageKey = "agents.system-prompt";
const meta: Meta<typeof AgentsEmptyState> = {
title: "pages/AgentsPage/AgentsEmptyState",
component: AgentsEmptyState,
@@ -49,6 +47,10 @@ const meta: Meta<typeof AgentsEmptyState> = {
workspaces: [],
count: 0,
});
spyOn(API, "getChatSystemPrompt").mockResolvedValue({
system_prompt: "",
});
spyOn(API, "updateChatSystemPrompt").mockResolvedValue();
},
};
@@ -186,9 +188,9 @@ export const SavesBehaviorPromptAndRestores: Story = {
await userEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(localStorage.getItem(behaviorStorageKey)).toBe(
"You are a focused coding assistant.",
);
expect(API.updateChatSystemPrompt).toHaveBeenCalledWith({
system_prompt: "You are a focused coding assistant.",
});
});
},
};
+21 -24
View File
@@ -7,10 +7,12 @@ import {
chatKey,
chatModelConfigs,
chatModels,
chatSystemPrompt,
chats,
chatsKey,
createChat,
unarchiveChat,
updateChatSystemPrompt,
} from "api/queries/chats";
import { workspaces } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
@@ -67,7 +69,6 @@ import { WebPushButton } from "./WebPushButton";
export const emptyInputStorageKey = "agents.empty-input";
const selectedWorkspaceIdStorageKey = "agents.selected-workspace-id";
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
const systemPromptStorageKey = "agents.system-prompt";
const nilUUID = "00000000-0000-0000-0000-000000000000";
type ChatModelOption = ModelSelectorOption;
@@ -704,14 +705,15 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
onConfigureAgentsDialogOpenChange,
}) => {
const { organizations } = useDashboard();
const queryClient = useQueryClient();
const { initialInputValue, handleContentChange, submitDraft, resetDraft } =
useEmptyStateDraft();
const initialSystemPrompt = () => {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem(systemPromptStorageKey) ?? "";
};
const systemPromptQuery = useQuery(chatSystemPrompt());
const {
mutate: saveSystemPrompt,
isPending: isSavingSystemPrompt,
isError: isSaveSystemPromptError,
} = useMutation(updateChatSystemPrompt(queryClient));
const [initialLastModelConfigID] = useState(() => {
if (typeof window === "undefined") {
return "";
@@ -771,10 +773,9 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
modelOptions.some((modelOption) => modelOption.id === userSelectedModel)
? userSelectedModel
: preferredModelID;
const [savedSystemPrompt, setSavedSystemPrompt] =
useState(initialSystemPrompt);
const [systemPromptDraft, setSystemPromptDraft] =
useState(initialSystemPrompt);
const serverPrompt = systemPromptQuery.data?.system_prompt ?? "";
const [localEdit, setLocalEdit] = useState<string | null>(null);
const systemPromptDraft = localEdit ?? serverPrompt;
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
() => {
@@ -832,7 +833,7 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
selectedWorkspaceIdRef.current = selectedWorkspaceId;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const isSystemPromptDirty = systemPromptDraft !== savedSystemPrompt;
const isSystemPromptDirty = localEdit !== null && localEdit !== serverPrompt;
const handleWorkspaceChange = (value: string) => {
if (value === autoCreateWorkspaceValue) {
@@ -859,17 +860,12 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
if (!isSystemPromptDirty) {
return;
}
setSavedSystemPrompt(systemPromptDraft);
if (typeof window !== "undefined") {
if (systemPromptDraft) {
localStorage.setItem(systemPromptStorageKey, systemPromptDraft);
} else {
localStorage.removeItem(systemPromptStorageKey);
}
}
saveSystemPrompt(
{ system_prompt: systemPromptDraft },
{ onSuccess: () => setLocalEdit(null) },
);
},
[isSystemPromptDirty, systemPromptDraft],
[isSystemPromptDirty, systemPromptDraft, saveSystemPrompt],
);
const handleSend = useCallback(
@@ -1013,10 +1009,11 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
canManageChatModelConfigs={canManageChatModelConfigs}
canSetSystemPrompt={canSetSystemPrompt}
systemPromptDraft={systemPromptDraft}
onSystemPromptDraftChange={setSystemPromptDraft}
onSystemPromptDraftChange={setLocalEdit}
onSaveSystemPrompt={handleSaveSystemPrompt}
isSystemPromptDirty={isSystemPromptDirty}
isDisabled={isCreating}
saveSystemPromptError={isSaveSystemPromptError}
isDisabled={isCreating || isSavingSystemPrompt}
/>
)}
</div>
@@ -78,6 +78,7 @@ const meta: Meta<typeof ConfigureAgentsDialog> = {
onSystemPromptDraftChange: fn(),
onSaveSystemPrompt: fn(),
isSystemPromptDirty: false,
saveSystemPromptError: false,
isDisabled: false,
},
};
@@ -32,6 +32,7 @@ interface ConfigureAgentsDialogProps {
onSystemPromptDraftChange: (value: string) => void;
onSaveSystemPrompt: (event: FormEvent) => void;
isSystemPromptDirty: boolean;
saveSystemPromptError: boolean;
isDisabled: boolean;
}
@@ -44,6 +45,7 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
onSystemPromptDraftChange,
onSaveSystemPrompt,
isSystemPromptDirty,
saveSystemPromptError,
isDisabled,
}) => {
const configureSectionOptions = useMemo<
@@ -152,7 +154,8 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
System Prompt
</h3>
<p className="m-0 text-xs text-content-secondary">
Admin-only instruction applied to all new chats.
Admin-only instruction applied to all new chats. When empty,
the built-in default prompt is used.
</p>
<TextareaAutosize
className="min-h-[220px] w-full resize-y rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-[13px] leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30"
@@ -182,6 +185,11 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
Save
</Button>
</div>
{saveSystemPromptError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save system prompt.
</p>
)}
</div>
</form>
</>