Skip to content

Commit cb37047

Browse files
authored
feat: dedicated /prompts endpoint for chat history cycle (coder#25083)
Follow-up to coder#25004. The merged change cycles only through messages already loaded in the in-memory chat store (page size 50). Long chats and chats whose oldest turns have rolled out of the page lose access to their earlier prompts in the composer's up/down arrow cycle. This PR adds a dedicated server endpoint that returns the full prompt history, newest first, and rewires the composer to use it. ## What changed ### Endpoint `GET /api/experimental/chats/{chat}/prompts?limit=N` ```go type ChatPrompt struct { ID int64; Text string } type ChatPromptsResponse struct { Prompts []ChatPrompt } ``` - `limit`: `0..2000`. `0` (the default) is treated as the server-side default of 500; out-of-range values return `400`. Negative values are rejected by the SDK's `PositiveInt32` parser before reaching the handler. - Auth: parent-chat read in `dbauthz`, mirroring `GetChatMessagesByChatID`. - The SQL filters `role='user'`, `deleted=false`, `visibility IN ('user','both')`, guards the lateral with `jsonb_typeof(content) = 'array'` so legacy V0 scalar-string rows are silently skipped, then unrolls `content` JSONB with `WITH ORDINALITY` and concatenates only `type='text'` parts in original order via `string_agg(... ORDER BY ordinality)`. Messages whose joined text is whitespace-only are dropped via `HAVING ... ~ '\S'` so cycling never lands on a blank entry. ### Partial index (migration `000494`) ```sql CREATE INDEX idx_chat_messages_user_prompts ON chat_messages (chat_id, id DESC) WHERE deleted = false AND role = 'user' AND visibility IN ('user', 'both'); ``` The partial WHERE matches the query's filter exactly and the key order matches `ORDER BY id DESC`, so the planner gets both the filter and the ordering from the index without a sort step. `EXPLAIN ANALYZE` on a synthetic 51-chat × 5,000-message dataset (≈260k rows, 10k user prompts in the target chat, `random_page_cost=1.1`): | | Plan | Buffers hit | Time | |---|---|---|---| | Without index | `Index Scan Backward using chat_messages_pkey`, **250,848 rows removed by filter** | 6,683 | 32.4 ms | | With index | `Index Scan using idx_chat_messages_user_prompts`, no filter | 38 | 1.3 ms | ≈25× faster, 175× fewer buffer hits. ### Frontend - `chatPromptsKey` / `chatPromptsQuery` factories in `site/src/api/queries/chats.ts` (`staleTime: 30s`, `enabled: chatId !== ""`, asks the server for 500 prompts). - `ChatPageContent.tsx` replaces the in-memory derivation with `useQuery(chatPromptsQuery(chatId ?? ""))`. The composer's existing `cycleHistorySnapshotRef` anchors the in-flight cycle so a refetch arriving mid-cycle cannot shift the indexed prompt out from under the user. - `getEditableUserMessagePayload` now concatenates user-message text parts verbatim, mirroring the server's `string_agg(part->>'text', '' ORDER BY ordinality)`, instead of routing through the streaming-oriented `parseMessageContent` / `appendText` pipeline (which drops whitespace-only chunks — correct for assistant streams, wrong for a user's persisted message). This keeps the cycle and the edit path in agreement on the same message. File blocks are still pulled separately via `parseMessageContent(...).blocks.filter(isEditableUserMessageFileBlock)`. - Cache invalidation in `createChatMessage.onSuccess`, `editChatMessage.onSettled`, and `useChatStore.upsertCacheMessages` (only when an upserted message has `role === "user"`). - Page-level stories pre-seed `chatPromptsKey(CHAT_ID)` from the same `messagesData` to keep them offline. ## Tests - New `TestGetChatUserPrompts` in `coderd/exp_chats_test.go` with five subtests: - `NewestFirstFiltering` — multi-part concatenation, non-text parts skipped, whitespace-only filtered, soft-deleted excluded, `model`-only visibility excluded, assistant-role excluded by `cm.role = 'user'`, legacy V0 scalar row silently excluded by the `jsonb_typeof` guard, ordering newest first. - `LimitClampsResults` — explicit `limit=2` returns the two newest prompts. - `InvalidLimitRejected` — `limit=5000` is `400 Bad Request`. - `NotFoundForOtherUsers` — a separate user in the same org gets `404`, not the prompts. - `EmptyResultIsJSONArray` — zero-message chat and assistant-only chat both return `Prompts: []` (non-nil, empty). - New unit test in `messageParsing.test.ts` asserting that `getEditableUserMessagePayload(["hello", " ", "world"])` returns `"hello world"`, locking in the agreement with the SQL `string_agg`. - `dbauthz_test.go` adds the `MethodTestSuite.TestChats/GetChatUserPromptsByChatID` entry, asserting parent-chat `policy.ActionRead`. - `pnpm test src/pages/AgentsPage` — 1159 passed, 2 skipped. - `make gen` produces no diff. ## Manual verification Seeded a dev chat with Claude Sonnet 4.6 via the aibridge Anthropic provider and posted 20 user prompts end-to-end. Verified that the `/prompts` endpoint returns 20 rows newest-first, that `limit=10` clamps correctly, that `limit=0` uses the server default of 500, and that the up/down keyboard cycle in the composer walks the same sequence (and reverses correctly back to the empty draft). ## Out of scope - Cross-chat history. - Per-user opt-out for the cycle. - File-reference / attachment cycling — the cycle continues to reproduce plain text only, by design. <details> <summary>Implementation plan</summary> # CODAGT-319 Follow-up — Dedicated `/prompts` endpoint ## Context The merged feature ([coder#25004](coder#25004) / [d32842f](coder@d32842f)) cycles only through messages already loaded in the in-memory chat store, which is capped at the first 50 messages of the current page. Long chats and chats whose oldest turns have rolled out of the page can no longer recall their full prompt history. This follow-up exposes a dedicated server endpoint that returns the user-authored prompts in a chat, newest first, and rewires the composer to use it. ## Design ### Endpoint `GET /api/experimental/chats/{chat}/prompts?limit=N` Returns: ```go type ChatPrompt struct { ID int64 Text string } type ChatPromptsResponse struct { Prompts []ChatPrompt } ``` - `limit`: `0..2000`. `0` (the default) → server-side default of 500. The wire-level default is encoded in SQL as `COALESCE(NULLIF($limit, 0), 500)`. Negatives are rejected upstream by `PositiveInt32`; the handler only caps the upper bound. - Auth: parent-chat read in `dbauthz`, mirroring `GetChatMessagesByChatID`. - Listed under the experimental router so we can iterate without API guarantees. ### SQL The query lives in `coderd/database/queries/chats.sql` as `GetChatUserPromptsByChatID`: - Filters `role='user'`, `deleted=false`, `visibility IN ('user','both')` to mirror the composer's "what the user actually typed and can re-send" contract. - Guards the lateral with `jsonb_typeof(content) = 'array'` so legacy V0 rows whose content is a scalar JSON string (predates migration `000434`) are silently excluded instead of raising `"cannot extract elements from a scalar"`. - Unrolls `content` JSONB with `jsonb_array_elements WITH ORDINALITY` and concatenates only `type='text'` parts, preserving original order via `string_agg(... ORDER BY ordinality)`. - Casts the result to `text` so sqlc emits a `string` field instead of `[]byte`. - Drops whitespace-only prompts via `HAVING string_agg(...) ~ '\S'` so cycling never lands on a blank entry. - Orders by `cm.id DESC` (`id` is a sequence, so this is "newest first" without relying on `created_at`). ### Index New partial index added in migration `000494`: ```sql CREATE INDEX idx_chat_messages_user_prompts ON chat_messages (chat_id, id DESC) WHERE deleted = false AND role = 'user' AND visibility IN ('user', 'both'); ``` The partial WHERE clause matches the query's filter exactly, so the planner can use the index for both filtering and ordering without a sort step. ### Frontend - `chatPromptsKey(chatId)` and `chatPromptsQuery(chatId)` factories in `site/src/api/queries/chats.ts`. `staleTime: 30s`, `enabled: chatId !== ""`. Asks the server for 500 prompts (well below the 2000 max, plenty for the cycle). - `ChatPageContent.tsx` replaces the in-memory derivation with `useQuery(chatPromptsQuery(chatId ?? ""))`. The composer's `cycleHistorySnapshotRef` already takes a stable snapshot at cycle entry, so a refetch arriving mid-cycle cannot shift the indexed prompt out from under the user. - `getEditableUserMessagePayload` extracts the edit-path text from raw user-message parts (filter `type === "text"`, join verbatim) instead of going through `parseMessageContent` / `appendText`, which is built for assistant streams and intentionally drops whitespace-only chunks. Without this, cycling and clicking Edit on the same message could produce different draft text for messages with whitespace-only interleaved text parts. - Cache invalidation: `createChatMessage.onSuccess`, `editChatMessage.onSettled`, and `useChatStore.upsertCacheMessages` (when at least one upserted message has `role === "user"`) all invalidate `chatPromptsKey(chatId)`. ### Tests - `TestGetChatUserPrompts` (`coderd/exp_chats_test.go`) covers: - `NewestFirstFiltering` — multi-part concatenation, non-text parts skipped, whitespace-only filtered, soft-deleted excluded, `model`-only visibility excluded, assistant-role excluded by `cm.role = 'user'`, legacy V0 scalar row silently excluded by the `jsonb_typeof` guard, ordering newest first. - `LimitClampsResults` — explicit `limit=2` returns the two newest prompts. - `InvalidLimitRejected` — `limit=5000` is `400 Bad Request`. - `NotFoundForOtherUsers` — a separate user in the same org gets `404`, not the prompts. - `EmptyResultIsJSONArray` — zero-message chat and assistant-only chat both return `Prompts: []` (non-nil, empty). - `messageParsing.test.ts` adds a unit test asserting that `getEditableUserMessagePayload(["hello", " ", "world"])` returns `"hello world"`, locking in the agreement with the SQL `string_agg`. - `dbauthz_test.go` adds the `MethodTestSuite.TestChats/GetChatUserPromptsByChatID` entry, asserting the parent-chat `policy.ActionRead`. ## Out of scope - Cross-chat history. - Per-user opt-out for the cycle. - File-reference / attachment cycling — the cycle still reproduces plain text only, by design. </details> <details> <summary>coder-agents-review history</summary> Four review rounds, eight unique findings, all addressed in this PR (approved twice). Rebased onto `main` twice after R4: first to pick up new migrations `000491` / `000492`, then again for `000493_idx_chat_diff_statuses_url_lower`. The prompts-index migration was renumbered `000491 → 000493 → 000494` via `coderd/database/migrations/fix_migration_numbers.sh`; no other diff changes. | Round | Head | Outcome | |---|---|---| | R1 | `725422ab` | `COMMENTED` — 7 findings (DEREM-1..7) | | R2 | `ab2a8936` | `COMMENTED` — 1 new (DEREM-10) + 1 reraised (DEREM-5) | | R3 | `648c5d1f` | **`APPROVED`** — 7 fixed, DEREM-5 deferred via coder#25125 | | R4 | `93b6f450` | **`APPROVED`** — DEREM-5 also fixed in-PR, coder#25125 closed | | ID | Where | Resolution | |---|---|---| | DEREM-1 | `chats.sql` | Added `jsonb_typeof(content) = 'array'` guard against V0 scalar rows | | DEREM-2 | `exp_chats.go` | Removed dead `limit < 0` branch (SDK rejects upstream) | | DEREM-3 | `useChatStore.ts` | Rewrote misleading invalidation comment | | DEREM-4 | `exp_chats_test.go` | `NewestFirstFiltering` now inserts an assistant-role message so the `role='user'` filter is exercised end-to-end | | DEREM-5 | `messageParsing.ts` | Rewrote `getEditableUserMessagePayload` to concatenate text parts verbatim, mirroring the SQL `string_agg` | | DEREM-6 | `exp_chats.go` | Tightened swagger doc + error message to spell out the 0–2000 range | | DEREM-7 | `exp_chats_test.go` | Added `EmptyResultIsJSONArray` subtest | | DEREM-10 | `exp_chats_test.go` | `NewestFirstFiltering` now inserts a raw V0 scalar-content row; verified locally that removing the guard makes the test fail | </details> --- This PR was created on behalf of @ibetitsmike by Coder Agents.
1 parent f71bccf commit cb37047

26 files changed

Lines changed: 990 additions & 18 deletions

coderd/apidoc/docs.go

Lines changed: 64 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,6 +1286,7 @@ func New(options *Options) *API {
12861286
r.Get("/messages", api.getChatMessages)
12871287
r.Post("/messages", api.postChatMessages)
12881288
r.Patch("/messages/{message}", api.patchChatMessage)
1289+
r.Get("/prompts", api.getChatUserPrompts)
12891290
r.Route("/stream", func(r chi.Router) {
12901291
r.Get("/", api.streamChat)
12911292
r.Get("/desktop", api.watchChatDesktop)

coderd/database/dbauthz/dbauthz.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3065,6 +3065,15 @@ func (q *querier) GetChatUsageLimitUserOverride(ctx context.Context, userID uuid
30653065
return q.db.GetChatUsageLimitUserOverride(ctx, userID)
30663066
}
30673067

3068+
func (q *querier) GetChatUserPromptsByChatID(ctx context.Context, arg database.GetChatUserPromptsByChatIDParams) ([]database.GetChatUserPromptsByChatIDRow, error) {
3069+
// Authorize read on the parent chat.
3070+
_, err := q.GetChatByID(ctx, arg.ChatID)
3071+
if err != nil {
3072+
return nil, err
3073+
}
3074+
return q.db.GetChatUserPromptsByChatID(ctx, arg)
3075+
}
3076+
30683077
func (q *querier) GetChatWorkspaceTTL(ctx context.Context) (string, error) {
30693078
// The workspace-TTL setting is a deployment-wide value read by any
30703079
// authenticated chat user. We only require that an explicit actor is

coderd/database/dbauthz/dbauthz_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,14 @@ func (s *MethodTestSuite) TestChats() {
813813
dbm.EXPECT().GetChatMessagesByChatIDDescPaginated(gomock.Any(), arg).Return(msgs, nil).AnyTimes()
814814
check.Args(arg).Asserts(chat, policy.ActionRead).Returns(msgs)
815815
}))
816+
s.Run("GetChatUserPromptsByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
817+
chat := testutil.Fake(s.T(), faker, database.Chat{})
818+
rows := []database.GetChatUserPromptsByChatIDRow{{ID: 1, Text: "hello"}}
819+
arg := database.GetChatUserPromptsByChatIDParams{ChatID: chat.ID, LimitVal: 500}
820+
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
821+
dbm.EXPECT().GetChatUserPromptsByChatID(gomock.Any(), arg).Return(rows, nil).AnyTimes()
822+
check.Args(arg).Asserts(chat, policy.ActionRead).Returns(rows)
823+
}))
816824
s.Run("GetLastChatMessageByRole", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
817825
chat := testutil.Fake(s.T(), faker, database.Chat{})
818826
msg := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID})

coderd/database/dbmetrics/querymetrics.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbmock/dbmock.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dump.sql

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP INDEX IF EXISTS idx_chat_messages_user_prompts;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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])));

0 commit comments

Comments
 (0)