From cb37047dce6325c7d8e90123f87712b83a44ea44 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 14 May 2026 12:43:12 +0200
Subject: [PATCH] feat: dedicated /prompts endpoint for chat history cycle
(#25083)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follow-up to #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.
Implementation plan
# CODAGT-319 Follow-up — Dedicated `/prompts` endpoint
## Context
The merged feature ([#25004](https://github.com/coder/coder/pull/25004)
/ [d32842f](https://github.com/coder/coder/commit/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.
coder-agents-review history
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
#25125 |
| R4 | `93b6f450` | **`APPROVED`** — DEREM-5 also fixed in-PR, #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 |
---
This PR was created on behalf of @ibetitsmike by Coder Agents.
---
coderd/apidoc/docs.go | 64 ++++
coderd/apidoc/swagger.json | 60 +++
coderd/coderd.go | 1 +
coderd/database/dbauthz/dbauthz.go | 9 +
coderd/database/dbauthz/dbauthz_test.go | 8 +
coderd/database/dbmetrics/querymetrics.go | 8 +
coderd/database/dbmock/dbmock.go | 15 +
coderd/database/dump.sql | 2 +
..._chat_messages_user_prompts_index.down.sql | 1 +
...94_chat_messages_user_prompts_index.up.sql | 1 +
coderd/database/querier.go | 10 +
coderd/database/queries.sql.go | 66 ++++
coderd/database/queries/chats.sql | 32 ++
coderd/exp_chats.go | 72 ++++
coderd/exp_chats_test.go | 349 ++++++++++++++++++
codersdk/chats.go | 52 +++
docs/reference/api/chats.md | 51 +++
docs/reference/api/schemas.md | 35 ++
site/src/api/api.ts | 16 +
site/src/api/queries/chats.ts | 23 ++
site/src/api/typesGenerated.ts | 36 ++
.../AgentsPage/AgentChatPage.stories.tsx | 31 ++
.../ChatConversation/messageParsing.test.ts | 22 ++
.../ChatConversation/messageParsing.ts | 8 +-
.../ChatConversation/useChatStore.ts | 14 +-
.../AgentsPage/components/ChatPageContent.tsx | 22 +-
26 files changed, 990 insertions(+), 18 deletions(-)
create mode 100644 coderd/database/migrations/000494_chat_messages_user_prompts_index.down.sql
create mode 100644 coderd/database/migrations/000494_chat_messages_user_prompts_index.up.sql
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