diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f75ffe1bbc..9ed526da6c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -694,6 +694,48 @@ const docTemplate = `{ ] } }, + "/api/experimental/chats/{chat}/prompts": { + "get": { + "description": "Experimental: this endpoint is subject to change.\n\nReturns the user-authored prompts in a chat, newest first,\nwith each prompt's text parts concatenated in the order they\nwere authored. Used by the composer to power the up/down\narrow prompt-history cycle without paging through every\nmessage in the chat.", + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "List chat user prompts", + "operationId": "list-chat-user-prompts", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page size, 0 to 2000. 0 (the default) means the server-side default of 500.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatPromptsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/experimental/chats/{chat}/stream": { "get": { "description": "Experimental: this endpoint is subject to change.", @@ -16188,6 +16230,28 @@ const docTemplate = `{ "ChatPlanModePlan" ] }, + "codersdk.ChatPrompt": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "text": { + "type": "string" + } + } + }, + "codersdk.ChatPromptsResponse": { + "type": "object", + "properties": { + "prompts": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatPrompt" + } + } + } + }, "codersdk.ChatQueuedMessage": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index be824246a8..15d1131f7f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -611,6 +611,44 @@ ] } }, + "/api/experimental/chats/{chat}/prompts": { + "get": { + "description": "Experimental: this endpoint is subject to change.\n\nReturns the user-authored prompts in a chat, newest first,\nwith each prompt's text parts concatenated in the order they\nwere authored. Used by the composer to power the up/down\narrow prompt-history cycle without paging through every\nmessage in the chat.", + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "List chat user prompts", + "operationId": "list-chat-user-prompts", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page size, 0 to 2000. 0 (the default) means the server-side default of 500.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatPromptsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/experimental/chats/{chat}/stream": { "get": { "description": "Experimental: this endpoint is subject to change.", @@ -14593,6 +14631,28 @@ "enum": ["plan"], "x-enum-varnames": ["ChatPlanModePlan"] }, + "codersdk.ChatPrompt": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "text": { + "type": "string" + } + } + }, + "codersdk.ChatPromptsResponse": { + "type": "object", + "properties": { + "prompts": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatPrompt" + } + } + } + }, "codersdk.ChatQueuedMessage": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index bac2d94a4a..dd67488264 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1286,6 +1286,7 @@ func New(options *Options) *API { r.Get("/messages", api.getChatMessages) r.Post("/messages", api.postChatMessages) r.Patch("/messages/{message}", api.patchChatMessage) + r.Get("/prompts", api.getChatUserPrompts) r.Route("/stream", func(r chi.Router) { r.Get("/", api.streamChat) r.Get("/desktop", api.watchChatDesktop) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 83c391068b..ab6bd9d05a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3065,6 +3065,15 @@ func (q *querier) GetChatUsageLimitUserOverride(ctx context.Context, userID uuid return q.db.GetChatUsageLimitUserOverride(ctx, userID) } +func (q *querier) GetChatUserPromptsByChatID(ctx context.Context, arg database.GetChatUserPromptsByChatIDParams) ([]database.GetChatUserPromptsByChatIDRow, error) { + // Authorize read on the parent chat. + _, err := q.GetChatByID(ctx, arg.ChatID) + if err != nil { + return nil, err + } + return q.db.GetChatUserPromptsByChatID(ctx, arg) +} + func (q *querier) GetChatWorkspaceTTL(ctx context.Context) (string, error) { // The workspace-TTL setting is a deployment-wide value read by any // authenticated chat user. We only require that an explicit actor is diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 11fdb3e47a..0f25f59c34 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -813,6 +813,14 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatMessagesByChatIDDescPaginated(gomock.Any(), arg).Return(msgs, nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionRead).Returns(msgs) })) + s.Run("GetChatUserPromptsByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + rows := []database.GetChatUserPromptsByChatIDRow{{ID: 1, Text: "hello"}} + arg := database.GetChatUserPromptsByChatIDParams{ChatID: chat.ID, LimitVal: 500} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().GetChatUserPromptsByChatID(gomock.Any(), arg).Return(rows, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionRead).Returns(rows) + })) s.Run("GetLastChatMessageByRole", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) msg := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 12191ac07e..9cd1bea9f9 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1529,6 +1529,14 @@ func (m queryMetricsStore) GetChatUsageLimitUserOverride(ctx context.Context, us return r0, r1 } +func (m queryMetricsStore) GetChatUserPromptsByChatID(ctx context.Context, arg database.GetChatUserPromptsByChatIDParams) ([]database.GetChatUserPromptsByChatIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetChatUserPromptsByChatID(ctx, arg) + m.queryLatencies.WithLabelValues("GetChatUserPromptsByChatID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatUserPromptsByChatID").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatWorkspaceTTL(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetChatWorkspaceTTL(ctx) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 1eab730283..8832a8e8cb 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2823,6 +2823,21 @@ func (mr *MockStoreMockRecorder) GetChatUsageLimitUserOverride(ctx, userID any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatUsageLimitUserOverride", reflect.TypeOf((*MockStore)(nil).GetChatUsageLimitUserOverride), ctx, userID) } +// GetChatUserPromptsByChatID mocks base method. +func (m *MockStore) GetChatUserPromptsByChatID(ctx context.Context, arg database.GetChatUserPromptsByChatIDParams) ([]database.GetChatUserPromptsByChatIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatUserPromptsByChatID", ctx, arg) + ret0, _ := ret[0].([]database.GetChatUserPromptsByChatIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatUserPromptsByChatID indicates an expected call of GetChatUserPromptsByChatID. +func (mr *MockStoreMockRecorder) GetChatUserPromptsByChatID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatUserPromptsByChatID", reflect.TypeOf((*MockStore)(nil).GetChatUserPromptsByChatID), ctx, arg) +} + // GetChatWorkspaceTTL mocks base method. func (m *MockStore) GetChatWorkspaceTTL(ctx context.Context) (string, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 3c60581d5e..c19263b918 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3869,6 +3869,8 @@ CREATE INDEX idx_chat_messages_created_at ON chat_messages USING btree (created_ CREATE INDEX idx_chat_messages_owner_spend ON chat_messages USING btree (chat_id, created_at) WHERE (total_cost_micros IS NOT NULL); +CREATE INDEX idx_chat_messages_user_prompts ON chat_messages USING btree (chat_id, id DESC) WHERE ((deleted = false) AND (role = 'user'::chat_message_role) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility]))); + CREATE INDEX idx_chat_model_configs_enabled ON chat_model_configs USING btree (enabled); CREATE INDEX idx_chat_model_configs_provider ON chat_model_configs USING btree (provider); diff --git a/coderd/database/migrations/000494_chat_messages_user_prompts_index.down.sql b/coderd/database/migrations/000494_chat_messages_user_prompts_index.down.sql new file mode 100644 index 0000000000..37c3f6349a --- /dev/null +++ b/coderd/database/migrations/000494_chat_messages_user_prompts_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_chat_messages_user_prompts; diff --git a/coderd/database/migrations/000494_chat_messages_user_prompts_index.up.sql b/coderd/database/migrations/000494_chat_messages_user_prompts_index.up.sql new file mode 100644 index 0000000000..80f823ae31 --- /dev/null +++ b/coderd/database/migrations/000494_chat_messages_user_prompts_index.up.sql @@ -0,0 +1 @@ +CREATE INDEX idx_chat_messages_user_prompts ON chat_messages USING btree (chat_id, id DESC) WHERE ((deleted = false) AND (role = 'user'::chat_message_role) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility]))); diff --git a/coderd/database/querier.go b/coderd/database/querier.go index cb6b243f5c..05401a7db2 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -379,6 +379,16 @@ type sqlcQuerier interface { GetChatUsageLimitConfig(ctx context.Context) (ChatUsageLimitConfig, error) GetChatUsageLimitGroupOverride(ctx context.Context, groupID uuid.UUID) (GetChatUsageLimitGroupOverrideRow, error) GetChatUsageLimitUserOverride(ctx context.Context, userID uuid.UUID) (GetChatUsageLimitUserOverrideRow, error) + // Returns the concatenated text of each user-visible user prompt in a + // chat, newest first. Used by the composer to populate the up/down + // arrow prompt-history cycle. Non-text parts (tool calls, files, + // attachments, ...) are excluded; messages whose text payload is + // entirely whitespace are dropped so cycling never lands on a blank + // entry. The jsonb_typeof guard skips legacy V0 rows whose content is + // a scalar JSON string (predates migration 000434) so the lateral + // jsonb_array_elements never raises "cannot extract elements from a + // scalar". Backed by idx_chat_messages_user_prompts. + GetChatUserPromptsByChatID(ctx context.Context, arg GetChatUserPromptsByChatIDParams) ([]GetChatUserPromptsByChatIDRow, error) // Returns the global TTL for chat workspaces as a Go duration string. // Returns "0s" (disabled) when no value has been configured. GetChatWorkspaceTTL(ctx context.Context) (string, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8befc53e36..92158bc1e9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6950,6 +6950,72 @@ func (q *sqlQuerier) GetChatUsageLimitUserOverride(ctx context.Context, userID u return i, err } +const getChatUserPromptsByChatID = `-- name: GetChatUserPromptsByChatID :many +SELECT + cm.id, + string_agg(part->>'text', '' ORDER BY ordinality)::text AS text +FROM + chat_messages cm, + jsonb_array_elements(cm.content) WITH ORDINALITY AS t(part, ordinality) +WHERE + cm.chat_id = $1::uuid + AND cm.role = 'user' + AND cm.deleted = false + AND cm.visibility IN ('user', 'both') + AND jsonb_typeof(cm.content) = 'array' + AND part->>'type' = 'text' +GROUP BY + cm.id +HAVING + string_agg(part->>'text', '') ~ '\S' +ORDER BY + cm.id DESC +LIMIT + COALESCE(NULLIF($2::int, 0), 500) +` + +type GetChatUserPromptsByChatIDParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + LimitVal int32 `db:"limit_val" json:"limit_val"` +} + +type GetChatUserPromptsByChatIDRow struct { + ID int64 `db:"id" json:"id"` + Text string `db:"text" json:"text"` +} + +// Returns the concatenated text of each user-visible user prompt in a +// chat, newest first. Used by the composer to populate the up/down +// arrow prompt-history cycle. Non-text parts (tool calls, files, +// attachments, ...) are excluded; messages whose text payload is +// entirely whitespace are dropped so cycling never lands on a blank +// entry. The jsonb_typeof guard skips legacy V0 rows whose content is +// a scalar JSON string (predates migration 000434) so the lateral +// jsonb_array_elements never raises "cannot extract elements from a +// scalar". Backed by idx_chat_messages_user_prompts. +func (q *sqlQuerier) GetChatUserPromptsByChatID(ctx context.Context, arg GetChatUserPromptsByChatIDParams) ([]GetChatUserPromptsByChatIDRow, error) { + rows, err := q.db.QueryContext(ctx, getChatUserPromptsByChatID, arg.ChatID, arg.LimitVal) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetChatUserPromptsByChatIDRow + for rows.Next() { + var i GetChatUserPromptsByChatIDRow + if err := rows.Scan(&i.ID, &i.Text); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getChats = `-- name: GetChats :many SELECT chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type, chats.last_turn_summary, diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 23dec2dbf7..5666887076 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -277,6 +277,38 @@ ORDER BY LIMIT COALESCE(NULLIF(@limit_val::int, 0), 50); +-- name: GetChatUserPromptsByChatID :many +-- Returns the concatenated text of each user-visible user prompt in a +-- chat, newest first. Used by the composer to populate the up/down +-- arrow prompt-history cycle. Non-text parts (tool calls, files, +-- attachments, ...) are excluded; messages whose text payload is +-- entirely whitespace are dropped so cycling never lands on a blank +-- entry. The jsonb_typeof guard skips legacy V0 rows whose content is +-- a scalar JSON string (predates migration 000434) so the lateral +-- jsonb_array_elements never raises "cannot extract elements from a +-- scalar". Backed by idx_chat_messages_user_prompts. +SELECT + cm.id, + string_agg(part->>'text', '' ORDER BY ordinality)::text AS text +FROM + chat_messages cm, + jsonb_array_elements(cm.content) WITH ORDINALITY AS t(part, ordinality) +WHERE + cm.chat_id = @chat_id::uuid + AND cm.role = 'user' + AND cm.deleted = false + AND cm.visibility IN ('user', 'both') + AND jsonb_typeof(cm.content) = 'array' + AND part->>'type' = 'text' +GROUP BY + cm.id +HAVING + string_agg(part->>'text', '') ~ '\S' +ORDER BY + cm.id DESC +LIMIT + COALESCE(NULLIF(@limit_val::int, 0), 500); + -- name: GetChatMessagesForPromptByChatID :many WITH latest_compressed_summary AS ( SELECT diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index d9f99c7df0..ea27c3c2ea 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -2091,6 +2091,78 @@ func (api *API) getChatMessages(rw http.ResponseWriter, r *http.Request) { }) } +// @Summary List chat user prompts +// @ID list-chat-user-prompts +// @Security CoderSessionToken +// @Tags Chats +// @Produce json +// @Param chat path string true "Chat ID" format(uuid) +// @Param limit query int false "Page size, 0 to 2000. 0 (the default) means the server-side default of 500." +// @Success 200 {object} codersdk.ChatPromptsResponse +// @Router /api/experimental/chats/{chat}/prompts [get] +// @Description Experimental: this endpoint is subject to change. +// @Description +// @Description Returns the user-authored prompts in a chat, newest first, +// @Description with each prompt's text parts concatenated in the order they +// @Description were authored. Used by the composer to power the up/down +// @Description arrow prompt-history cycle without paging through every +// @Description message in the chat. +// +//nolint:revive // HTTP handler writes to ResponseWriter. +func (api *API) getChatUserPrompts(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + chatID := chat.ID + + queryParams := r.URL.Query() + parser := httpapi.NewQueryParamParser() + // Default 0 sentinel; the SQL query treats 0 as "use the built-in + // default of 500" via COALESCE(NULLIF(@limit_val, 0), 500). The + // SDK guards opts.Limit > 0 so callers using the typed client only + // reach here with an explicit value; raw HTTP callers can omit the + // parameter (or pass 0) to opt into the default. + limit := parser.PositiveInt32(queryParams, 0, "limit") + if len(parser.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: parser.Errors, + }) + return + } + // PositiveInt32 already rejects negatives via parser.Errors above, + // so we only need to cap the upper bound here. + if limit > 2000 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid limit parameter (0-2000).", + }) + return + } + + rows, err := api.Database.GetChatUserPromptsByChatID(ctx, database.GetChatUserPromptsByChatIDParams{ + ChatID: chatID, + LimitVal: limit, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat user prompts.", + Detail: err.Error(), + }) + return + } + + prompts := make([]codersdk.ChatPrompt, 0, len(rows)) + for _, row := range rows { + prompts = append(prompts, codersdk.ChatPrompt{ + ID: row.ID, + Text: row.Text, + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatPromptsResponse{ + Prompts: prompts, + }) +} + // authorizeChatWorkspaceExec enforces the workspace-level permissions // shared by the chat stream endpoints that proxy a live websocket into // the workspace agent (currently /stream/git and /stream/desktop). diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 5c860008ac..1ddd86be8c 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -4423,6 +4423,355 @@ func TestGetChat(t *testing.T) { }) } +func TestGetChatUserPrompts(t *testing.T) { + t.Parallel() + + insertUserMessage := func( + t *testing.T, + ctx context.Context, + db database.Store, + chatID uuid.UUID, + modelConfigID uuid.UUID, + userID uuid.UUID, + parts []codersdk.ChatMessagePart, + visibility database.ChatMessageVisibility, + deleted bool, + ) database.ChatMessage { + t.Helper() + content, err := chatprompt.MarshalParts(parts) + require.NoError(t, err) + msgs, err := db.InsertChatMessages(dbauthz.AsSystemRestricted(ctx), database.InsertChatMessagesParams{ + ChatID: chatID, + CreatedBy: []uuid.UUID{userID}, + ModelConfigID: []uuid.UUID{modelConfigID}, + Role: []database.ChatMessageRole{database.ChatMessageRoleUser}, + ContentVersion: []int16{chatprompt.CurrentContentVersion}, + Content: []string{string(content.RawMessage)}, + Visibility: []database.ChatMessageVisibility{visibility}, + InputTokens: []int64{0}, + OutputTokens: []int64{0}, + TotalTokens: []int64{0}, + ReasoningTokens: []int64{0}, + CacheCreationTokens: []int64{0}, + CacheReadTokens: []int64{0}, + ContextLimit: []int64{0}, + Compressed: []bool{false}, + TotalCostMicros: []int64{0}, + RuntimeMs: []int64{0}, + }) + require.NoError(t, err) + require.Len(t, msgs, 1) + if deleted { + require.NoError(t, db.SoftDeleteChatMessageByID(dbauthz.AsSystemRestricted(ctx), msgs[0].ID)) + } + return msgs[0] + } + + t.Run("NewestFirstFiltering", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OrganizationID: user.OrganizationID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "prompts route test", + }) + require.NoError(t, err) + + // Older user prompt with multiple text parts that need + // concatenation in original order. + want1 := insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID, + []codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "first "}, + {Type: codersdk.ChatMessagePartTypeText, Text: "prompt"}, + }, + database.ChatMessageVisibilityBoth, false, + ) + + // User prompt with a non-text part interleaved; only text + // parts should appear in the response, joined verbatim. + want2 := insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID, + []codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "hello "}, + {Type: codersdk.ChatMessagePartTypeFile, MediaType: "text/plain", Data: []byte("x")}, + {Type: codersdk.ChatMessagePartTypeText, Text: "world"}, + }, + database.ChatMessageVisibilityBoth, false, + ) + + // Whitespace-only prompt; must be filtered out by the + // HAVING clause so cycling never lands on a blank entry. + insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID, + []codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: " \n\t "}, + }, + database.ChatMessageVisibilityBoth, false, + ) + + // Assistant-role message with otherwise-valid content; + // the SQL filter cm.role = 'user' must exclude it from + // the response. + assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "assistant reply"}, + }) + require.NoError(t, err) + _, err = db.InsertChatMessages(dbauthz.AsSystemRestricted(ctx), database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedBy: []uuid.UUID{user.UserID}, + ModelConfigID: []uuid.UUID{modelConfig.ID}, + Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, + ContentVersion: []int16{chatprompt.CurrentContentVersion}, + Content: []string{string(assistantContent.RawMessage)}, + Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth}, + InputTokens: []int64{0}, + OutputTokens: []int64{0}, + TotalTokens: []int64{0}, + ReasoningTokens: []int64{0}, + CacheCreationTokens: []int64{0}, + CacheReadTokens: []int64{0}, + ContextLimit: []int64{0}, + Compressed: []bool{false}, + TotalCostMicros: []int64{0}, + RuntimeMs: []int64{0}, + }) + require.NoError(t, err) + + // Legacy V0 user message stored as a scalar JSON string + // (predates migration 000434). The jsonb_typeof guard in + // GetChatUserPromptsByChatID must silently exclude this row; + // without the guard, jsonb_array_elements would raise + // "cannot extract elements from a scalar" and the request + // would 500. + _, err = db.InsertChatMessages(dbauthz.AsSystemRestricted(ctx), database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedBy: []uuid.UUID{user.UserID}, + ModelConfigID: []uuid.UUID{modelConfig.ID}, + Role: []database.ChatMessageRole{database.ChatMessageRoleUser}, + ContentVersion: []int16{chatprompt.ContentVersionV0}, + Content: []string{`"plain text from V0"`}, + Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth}, + InputTokens: []int64{0}, + OutputTokens: []int64{0}, + TotalTokens: []int64{0}, + ReasoningTokens: []int64{0}, + CacheCreationTokens: []int64{0}, + CacheReadTokens: []int64{0}, + ContextLimit: []int64{0}, + Compressed: []bool{false}, + TotalCostMicros: []int64{0}, + RuntimeMs: []int64{0}, + }) + require.NoError(t, err) + + // Soft-deleted prompt; must not appear. + insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID, + []codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "deleted prompt"}, + }, + database.ChatMessageVisibilityBoth, true, + ) + + // Model-only visibility prompt; must not appear (composer + // only shows what the user actually typed). + insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID, + []codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "model only"}, + }, + database.ChatMessageVisibilityModel, false, + ) + + // Newest user-visible prompt; should come first in the + // response. + want3 := insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID, + []codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "newest prompt"}, + }, + database.ChatMessageVisibilityUser, false, + ) + + resp, err := client.GetChatPrompts(ctx, chat.ID, nil) + require.NoError(t, err) + require.Len(t, resp.Prompts, 3, "expected exactly the three user-visible non-blank prompts") + + require.Equal(t, want3.ID, resp.Prompts[0].ID) + require.Equal(t, "newest prompt", resp.Prompts[0].Text) + require.Equal(t, want2.ID, resp.Prompts[1].ID) + require.Equal(t, "hello world", resp.Prompts[1].Text) + require.Equal(t, want1.ID, resp.Prompts[2].ID) + require.Equal(t, "first prompt", resp.Prompts[2].Text) + }) + + t.Run("LimitClampsResults", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OrganizationID: user.OrganizationID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "prompts limit test", + }) + require.NoError(t, err) + + for i := 0; i < 5; i++ { + insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID, + []codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: fmt.Sprintf("prompt %d", i)}, + }, + database.ChatMessageVisibilityBoth, false, + ) + } + + resp, err := client.GetChatPrompts(ctx, chat.ID, &codersdk.ChatPromptsOptions{Limit: 2}) + require.NoError(t, err) + require.Len(t, resp.Prompts, 2) + require.Equal(t, "prompt 4", resp.Prompts[0].Text) + require.Equal(t, "prompt 3", resp.Prompts[1].Text) + }) + + t.Run("InvalidLimitRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OrganizationID: user.OrganizationID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "prompts invalid limit test", + }) + require.NoError(t, err) + + _, err = client.GetChatPrompts(ctx, chat.ID, &codersdk.ChatPromptsOptions{Limit: 5000}) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("NotFoundForOtherUsers", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OrganizationID: firstUser.OrganizationID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "prompts cross-owner test", + }) + require.NoError(t, err) + + insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, firstUser.UserID, + []codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "private prompt"}, + }, + database.ChatMessageVisibilityBoth, false, + ) + + memberClient, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) + memberExp := codersdk.NewExperimentalClient(memberClient) + _, err = memberExp.GetChatPrompts(ctx, chat.ID, nil) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("EmptyResultIsJSONArray", func(t *testing.T) { + t.Parallel() + + // Boundary: a chat with no user-visible prompts must + // serialize to {"prompts":[]}, not {"prompts":null}, so + // the composer's cycle code can branch on len() without + // guarding against nil. We exercise both branches: a chat + // with zero messages, and a chat that has only an + // assistant message (the SQL filter excludes it). + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + emptyChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OrganizationID: user.OrganizationID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "prompts empty chat test", + }) + require.NoError(t, err) + + resp, err := client.GetChatPrompts(ctx, emptyChat.ID, nil) + require.NoError(t, err) + require.NotNil(t, resp.Prompts, "prompts must be [] not nil") + require.Empty(t, resp.Prompts) + + assistantOnlyChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OrganizationID: user.OrganizationID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "prompts assistant-only chat test", + }) + require.NoError(t, err) + + assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "assistant reply"}, + }) + require.NoError(t, err) + _, err = db.InsertChatMessages(dbauthz.AsSystemRestricted(ctx), database.InsertChatMessagesParams{ + ChatID: assistantOnlyChat.ID, + CreatedBy: []uuid.UUID{user.UserID}, + ModelConfigID: []uuid.UUID{modelConfig.ID}, + Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, + ContentVersion: []int16{chatprompt.CurrentContentVersion}, + Content: []string{string(assistantContent.RawMessage)}, + Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth}, + InputTokens: []int64{0}, + OutputTokens: []int64{0}, + TotalTokens: []int64{0}, + ReasoningTokens: []int64{0}, + CacheCreationTokens: []int64{0}, + CacheReadTokens: []int64{0}, + ContextLimit: []int64{0}, + Compressed: []bool{false}, + TotalCostMicros: []int64{0}, + RuntimeMs: []int64{0}, + }) + require.NoError(t, err) + + resp, err = client.GetChatPrompts(ctx, assistantOnlyChat.ID, nil) + require.NoError(t, err) + require.NotNil(t, resp.Prompts, "prompts must be [] not nil") + require.Empty(t, resp.Prompts) + }) +} + func TestPatchChat(t *testing.T) { t.Parallel() diff --git a/codersdk/chats.go b/codersdk/chats.go index 7b0f8c198b..2baaf87e12 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -555,6 +555,23 @@ type ChatMessagesResponse struct { HasMore bool `json:"has_more"` } +// ChatPrompt is a single user-authored prompt in a chat, returned by +// GET /api/experimental/chats/{chat}/prompts. The text field contains +// the concatenated text payload of the underlying chat message; non-text +// parts (tool calls, files, attachments) are omitted by the server. +type ChatPrompt struct { + ID int64 `json:"id"` + Text string `json:"text"` +} + +// ChatPromptsResponse is the payload of +// GET /api/experimental/chats/{chat}/prompts. Prompts are returned +// newest first so the client can index directly into the slice for +// up/down arrow history cycling. +type ChatPromptsResponse struct { + Prompts []ChatPrompt `json:"prompts"` +} + // ChatModelProviderUnavailableReason explains why a provider cannot be used. type ChatModelProviderUnavailableReason string @@ -2960,6 +2977,41 @@ func (c *ExperimentalClient) GetChatMessages(ctx context.Context, chatID uuid.UU return resp, json.NewDecoder(res.Body).Decode(&resp) } +// ChatPromptsOptions are optional query parameters for GetChatPrompts. +type ChatPromptsOptions struct { + // Limit caps the number of prompts returned. The server enforces a + // minimum of 1 and a maximum of 2000; passing 0 (or negative) + // applies the server-side default of 500. + Limit int +} + +// GetChatPrompts returns the user prompts for a chat in newest-first +// order. It is a thin endpoint dedicated to the composer's prompt +// history cycle: only user-visible user messages are included, and +// only their text parts (concatenated in the original order) are +// returned. Whitespace-only prompts are filtered server-side so the +// caller never has to skip blank entries while cycling. +func (c *ExperimentalClient) GetChatPrompts(ctx context.Context, chatID uuid.UUID, opts *ChatPromptsOptions) (ChatPromptsResponse, error) { + reqOpts := []RequestOption{} + if opts != nil && opts.Limit > 0 { + reqOpts = append(reqOpts, func(r *http.Request) { + q := r.URL.Query() + q.Set("limit", strconv.Itoa(opts.Limit)) + r.URL.RawQuery = q.Encode() + }) + } + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/prompts", chatID), nil, reqOpts...) + if err != nil { + return ChatPromptsResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatPromptsResponse{}, ReadBodyAsError(res) + } + var resp ChatPromptsResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // UpdateChat patches a chat resource. func (c *ExperimentalClient) UpdateChat(ctx context.Context, chatID uuid.UUID, req UpdateChatRequest) error { res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/chats/%s", chatID), req) diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 90f7460d90..67c6bf6d6f 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -2121,6 +2121,57 @@ Experimental: this endpoint is subject to change. To perform this operation, you must be authenticated. [Learn more](authentication.md). +## List chat user prompts + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/experimental/chats/{chat}/prompts \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/experimental/chats/{chat}/prompts` + +Experimental: this endpoint is subject to change. + +Returns the user-authored prompts in a chat, newest first, +with each prompt's text parts concatenated in the order they +were authored. Used by the composer to power the up/down +arrow prompt-history cycle without paging through every +message in the chat. + +### Parameters + +| Name | In | Type | Required | Description | +|---------|-------|--------------|----------|-----------------------------------------------------------------------------| +| `chat` | path | string(uuid) | true | Chat ID | +| `limit` | query | integer | false | Page size, 0 to 2000. 0 (the default) means the server-side default of 500. | + +### Example responses + +> 200 Response + +```json +{ + "prompts": [ + { + "id": 0, + "text": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChatPromptsResponse](schemas.md#codersdkchatpromptsresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Stream chat events via WebSockets ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 1e87c428f4..8c21b01739 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3145,6 +3145,41 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in |----------| | `plan` | +## codersdk.ChatPrompt + +```json +{ + "id": 0, + "text": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------|---------|----------|--------------|-------------| +| `id` | integer | false | | | +| `text` | string | false | | | + +## codersdk.ChatPromptsResponse + +```json +{ + "prompts": [ + { + "id": 0, + "text": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|-----------------------------------------------------|----------|--------------|-------------| +| `prompts` | array of [codersdk.ChatPrompt](#codersdkchatprompt) | false | | | + ## codersdk.ChatQueuedMessage ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0ea9204654..eff0e3f957 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3135,6 +3135,22 @@ class ExperimentalApiMethods { return response.data; }; + /** + * Lists the user-authored prompts in a chat, newest first. + * Powers the composer's up/down arrow prompt-history cycle. + */ + getChatPrompts = async ( + chatId: string, + opts?: { limit?: number }, + ): Promise => { + const url = getURLWithSearchParams( + `/api/experimental/chats/${chatId}/prompts`, + opts, + ); + const response = await this.axios.get(url); + return response.data; + }; + createChat = async ( req: TypesGen.CreateChatRequest, ): Promise => { diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 89964a9d58..740e3ac1dc 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -20,6 +20,8 @@ export const chatsKey = ["chats"] as const; export const chatKey = (chatId: string) => ["chats", chatId] as const; export const chatMessagesKey = (chatId: string) => ["chats", chatId, "messages"] as const; +export const chatPromptsKey = (chatId: string) => + ["chats", chatId, "prompts"] as const; export const chatsByWorkspaceKeyPrefix = [...chatsKey, "by-workspace"] as const; @@ -560,6 +562,19 @@ export const chatMessagesForInfiniteScroll = (chatId: string) => ({ }, }); +// Cap requested prompts to keep the response small; well under the server-side maximum. +const PROMPT_HISTORY_LIMIT = 500; + +const PROMPTS_STALE_MS = 30_000; + +export const chatPromptsQuery = (chatId: string) => ({ + queryKey: chatPromptsKey(chatId), + queryFn: () => + API.experimental.getChatPrompts(chatId, { limit: PROMPT_HISTORY_LIMIT }), + staleTime: PROMPTS_STALE_MS, + enabled: chatId !== "", +}); + export const archiveChat = (queryClient: QueryClient) => ({ mutationFn: (chatId: string) => API.experimental.updateChat(chatId, { archived: true }), @@ -1149,6 +1164,10 @@ export const createChatMessage = ( API.experimental.createChatMessage(chatId, req), onSuccess: () => { void invalidateChatDebugRuns(queryClient, chatId); + void queryClient.invalidateQueries({ + queryKey: chatPromptsKey(chatId), + exact: true, + }); }, }); @@ -1238,6 +1257,10 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({ queryKey: chatKey(chatId), exact: true, }); + void queryClient.invalidateQueries({ + queryKey: chatPromptsKey(chatId), + exact: true, + }); void invalidateChatDebugRuns(queryClient, chatId); }, }); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8b43c2b317..e8892aa546 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2383,6 +2383,42 @@ export interface ChatPlanModeInstructionsResponse { export const ChatPlanModes: ChatPlanMode[] = ["plan"]; +// From codersdk/chats.go +/** + * ChatPrompt is a single user-authored prompt in a chat, returned by + * GET /api/experimental/chats/{chat}/prompts. The text field contains + * the concatenated text payload of the underlying chat message; non-text + * parts (tool calls, files, attachments) are omitted by the server. + */ +export interface ChatPrompt { + readonly id: number; + readonly text: string; +} + +// From codersdk/chats.go +/** + * ChatPromptsOptions are optional query parameters for GetChatPrompts. + */ +export interface ChatPromptsOptions { + /** + * Limit caps the number of prompts returned. The server enforces a + * minimum of 1 and a maximum of 2000; passing 0 (or negative) + * applies the server-side default of 500. + */ + readonly Limit: number; +} + +// From codersdk/chats.go +/** + * ChatPromptsResponse is the payload of + * GET /api/experimental/chats/{chat}/prompts. Prompts are returned + * newest first so the client can index directly into the slice for + * up/down arrow history cycling. + */ +export interface ChatPromptsResponse { + readonly prompts: readonly ChatPrompt[]; +} + // From codersdk/chats.go /** * ChatProviderConfig is an admin-managed provider configuration. diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index f246b0d132..7fea6ddf8b 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -14,6 +14,7 @@ import { chatMessagesKey, chatModelConfigs, chatModelsKey, + chatPromptsKey, chatsKey, mcpServerConfigsKey, } from "#/api/queries/chats"; @@ -160,6 +161,30 @@ index abc1234..def5678 100644 } `; +/** Extract user prompts from messages, newest first, mirroring + * the server's `/prompts` endpoint contract: role=user, non-empty + * after concatenating only `text` parts. + */ +const extractPromptsFromMessages = ( + messages: readonly TypesGen.ChatMessage[], +): TypesGen.ChatPrompt[] => { + const prompts: TypesGen.ChatPrompt[] = []; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role !== "user") { + continue; + } + const text = (message.content ?? []) + .filter((part): part is TypesGen.ChatTextPart => part.type === "text") + .map((part) => part.text) + .join(""); + if (text.trim()) { + prompts.push({ id: message.id, text }); + } + } + return prompts; +}; + /** Build `parameters.queries` entries for a given chat and messages. */ const buildQueries = ( chat: TypesGen.Chat, @@ -186,6 +211,12 @@ const buildQueries = ( key: chatMessagesKey(CHAT_ID), data: { pages: [messagesData], pageParams: [undefined] }, }, + { + key: chatPromptsKey(CHAT_ID), + data: { + prompts: extractPromptsFromMessages(messagesData.messages), + } satisfies TypesGen.ChatPromptsResponse, + }, { key: chatsKey, data: [chatWithDiffStatus] }, { key: chatDiffContentsKey(CHAT_ID), diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts index 41d2f6988e..e0e49ea51c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts @@ -152,6 +152,28 @@ describe("getEditableUserMessagePayload", () => { expect(getEditableUserMessagePayload(message)).toEqual(want); } }); + + it("preserves whitespace-only text parts when concatenating", () => { + // The server-side prompt-history cycle joins text parts + // verbatim via `string_agg(part->>'text', '' ORDER BY ordinality)`. + // The edit path must agree so cycling and clicking Edit on the + // same message produce the same draft text. + const message: ChatMessage = { + id: 1, + chat_id: "chat-1", + created_at: "2026-04-21T00:00:00.000Z", + role: "user", + content: [ + { type: "text", text: "hello" }, + { type: "text", text: " " }, + { type: "text", text: "world" }, + ], + }; + expect(getEditableUserMessagePayload(message)).toEqual({ + text: "hello world", + fileBlocks: undefined, + }); + }); }); describe("parseMessageContent", () => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index c665710cf4..4e62eb1383 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -256,10 +256,16 @@ export const getEditableUserMessagePayload = ( text: string; fileBlocks: readonly TypesGen.ChatMessagePart[] | undefined; } => { + // Concatenate text parts verbatim to match the server-side string_agg in + // GetChatUserPromptsByChatID; parseMessageContent/appendText is for streaming and drops whitespace-only chunks. + const text = (message.content ?? []) + .filter((part): part is TypesGen.ChatTextPart => part.type === "text") + .map((part) => part.text) + .join(""); const parsed = parseMessageContent(message.content); const fileBlocks = parsed.blocks.filter(isEditableUserMessageFileBlock); return { - text: parsed.markdown || "", + text, fileBlocks: fileBlocks.length > 0 ? fileBlocks : undefined, }; }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index b1fd4be1c4..d7fb56f0d3 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -7,7 +7,11 @@ import { } from "react"; import { type InfiniteData, useQueryClient } from "react-query"; import { watchChat } from "#/api/api"; -import { chatMessagesKey, updateInfiniteChatsCache } from "#/api/queries/chats"; +import { + chatMessagesKey, + chatPromptsKey, + updateInfiniteChatsCache, +} from "#/api/queries/chats"; import type * as TypesGen from "#/api/typesGenerated"; import type { OneWayMessageEvent } from "#/utils/OneWayWebSocket"; import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket"; @@ -168,6 +172,14 @@ export const useChatStore = ( ], }; }); + // Refresh the dedicated prompt-history cache when a user message arrives. + const hasNewUserPrompt = messages.some((msg) => msg.role === "user"); + if (hasNewUserPrompt) { + void queryClient.invalidateQueries({ + queryKey: chatPromptsKey(chatID), + exact: true, + }); + } }, [chatID, queryClient], ); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index a0afdfb5ee..f0775953d5 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -1,6 +1,8 @@ import { type FC, Profiler, type ReactNode, useEffect, useRef } from "react"; +import { useQuery } from "react-query"; import { toast } from "sonner"; import type { UrlTransform } from "streamdown"; +import { chatPromptsQuery } from "#/api/queries/chats"; import type * as TypesGen from "#/api/typesGenerated"; import type { AgentChatSendShortcut } from "#/api/typesGenerated"; import { cn } from "#/utils/cn"; @@ -32,7 +34,6 @@ import { import { LiveStreamTail } from "./ChatConversation/LiveStreamTail"; import { buildSubagentMaps, - getEditableUserMessagePayload, parseMessagesWithMergedTools, } from "./ChatConversation/messageParsing"; import { useOnRenderProfiler } from "./ChatConversation/useOnRenderProfiler"; @@ -288,21 +289,10 @@ export const ChatPageInput: FC = ({ return message; }) .filter(isChatMessage); - // Newest first. messages are in id-ascending order, so reverse - // iteration yields the most recent user prompts first. Store the - // original text untouched so cycling and re-sending reproduces what - // the user originally sent (boundary whitespace can be intentional). - const userPromptHistory: string[] = []; - for (let index = messages.length - 1; index >= 0; index--) { - const message = messages.at(index); - if (!message || message.role !== "user") { - continue; - } - const text = getEditableUserMessagePayload(message).text; - if (text.trim()) { - userPromptHistory.push(text); - } - } + // Source the composer's prompt-history cycle from the dedicated /prompts endpoint. + const { data: promptsData } = useQuery(chatPromptsQuery(chatId ?? "")); + const userPromptHistory: readonly string[] = + promptsData?.prompts.map((prompt) => prompt.text) ?? []; const rawUsage = getLatestContextUsage(messages); const latestContextUsage = rawUsage