Skip to content

feat: dedicated /prompts endpoint for chat history cycle#25083

Merged
ibetitsmike merged 1 commit into
mainfrom
codagt-319-prompts-endpoint
May 14, 2026
Merged

feat: dedicated /prompts endpoint for chat history cycle#25083
ibetitsmike merged 1 commit into
mainfrom
codagt-319-prompts-endpoint

Conversation

@ibetitsmike
Copy link
Copy Markdown
Collaborator

@ibetitsmike ibetitsmike commented May 8, 2026

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

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)

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.
    • InvalidLimitRejectedlimit=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 / 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:

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:

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.
    • InvalidLimitRejectedlimit=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.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

Docs preview

📖 View docs preview for docs/reference/api/chats.md

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 34fa90eee2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread coderd/database/queries/chats.sql
Copy link
Copy Markdown
Collaborator Author

/coder-agents-review

@ibetitsmike ibetitsmike changed the title feat(coderd): dedicated /prompts endpoint for chat history cycle feat: dedicated /prompts endpoint for chat history cycle May 11, 2026
@ibetitsmike ibetitsmike force-pushed the codagt-319-prompts-endpoint branch from 34fa90e to 725422a Compare May 11, 2026 13:32
Copy link
Copy Markdown
Collaborator Author

Mux is acting on Mike's behalf.

Copy link
Copy Markdown
Collaborator Author

/coder-agents-review

Copy link
Copy Markdown
Contributor

@coder-agents-review coder-agents-review Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-scoped PR. The partial index matches the SQL filter exactly, the dbauthz pattern mirrors sibling endpoints, and the frontend integration is clean with appropriate cache invalidation and snapshot-based cycling. The four subtests cover the key dimensions. One P2, four P3s, two P4s.

"The cycle path and the edit path extract different text from the same message." (Hisoka)

1 P2 (legacy scalar content guard), 4 P3 (dead code, comment mismatch, test gap, behavioral divergence), 2 P4 (limit=0 silent accept, empty-result test). The P2 aligns with the existing Codex comment on this PR. The fix is a one-line WHERE clause addition.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/queries/chats.sql
Comment thread coderd/exp_chats.go
Comment thread site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts Outdated
Comment thread coderd/exp_chats_test.go
Comment thread coderd/database/queries/chats.sql
Comment thread coderd/exp_chats.go Outdated
Comment thread coderd/exp_chats_test.go
@ibetitsmike ibetitsmike force-pushed the codagt-319-prompts-endpoint branch from 725422a to ab2a893 Compare May 11, 2026 15:13
Copy link
Copy Markdown
Collaborator Author

Mux is acting on Mike's behalf.

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Pushed ab2a8936 addressing the actionable items:

  • DEREM-1 (chats.sql:292): added AND jsonb_typeof(cm.content) = 'array' to guard against V0 scalar-string content rows. Refreshed the comment above the query to document the safety check.
  • DEREM-2 + DEREM-6 (exp_chats.go:2130-2131): removed the dead limit < 0 branch (the SDK already rejects negatives via PositiveInt32) and tightened the swagger doc/error message to spell out the accepted range as 0–2000, where 0 means "use the server default of 500." Added an inline comment explaining the asymmetry between the SDK guard and the raw HTTP fallback.
  • DEREM-3 (useChatStore.ts:175-181): rewrote the misleading comment to honestly describe the over-invalidation as a no-op (chat prompts query has staleTime: 30s), instead of claiming an ID check that doesn't exist in the code.
  • DEREM-4 (exp_chats_test.go NewestFirstFiltering): the test now inserts an assistant-role message between the two user prompts so the role='user' filter is actually exercised end-to-end.
  • DEREM-7 (exp_chats_test.go): added EmptyResultIsJSONArray subtest covering both zero-message chats and assistant-only chats — asserts Prompts is non-nil and empty.
  • DEREM-5 (informational, whitespace divergence vs appendText in the edit path): no code change; tracked as a follow-up since the bot flagged it as an existing divergence rather than a regression.

Local gates green: go test -count=1 ./coderd/ -run TestGetChatUserPrompts, TestMethodTestSuite ./coderd/database/dbauthz/, pnpm format/check/lint/test src/pages/AgentsPage --run.

/coder-agents-review

@ibetitsmike ibetitsmike force-pushed the codagt-319-prompts-endpoint branch from ab2a893 to e96f371 Compare May 11, 2026 15:34
Copy link
Copy Markdown
Collaborator Author

Rebased onto latest main (85792d08); renamed migration to 000491 to avoid collision with the new 000490_trigger_delete_user_secrets. No code changes — head is now e96f3710.

/coder-agents-review

Copy link
Copy Markdown
Contributor

@coder-agents-review coder-agents-review Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R1 fixes verified: DEREM-1 (V0 guard), -2 (dead branch), -3 (comment rewrite), -4 (assistant-role test), -6 (limit=0 docs), -7 (empty result test) all confirmed by Netero and 4-reviewer panel. Solid follow-through.

DEREM-5 (whitespace divergence): panel accepts the author's defense that the edit path's appendText is the pre-existing quirk and the SQL behavior is more faithful. However, the divergence is new (this PR switched the cycle path away from the shared extraction), there is no linked ticket, and a deferral without a ticket is a drop. This needs a human decision: file a ticket for the appendText alignment or explicitly accept the gap.

One new P3: the jsonb_typeof guard that prevents the P2 crash on V0 rows has no regression test. Two reviewers converged on this independently.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/exp_chats_test.go
Copy link
Copy Markdown
Contributor

@coder-agents-review coder-agents-review Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review blocked. DEREM-10 (P3, no regression test for the V0 jsonb_typeof guard) was raised in R2 and has no response. The R3 push was a rebase only (migration renumbered to 000491, no code changes).

Open items before further review:

  1. DEREM-10 (P3): Insert a V0-style scalar content row in NewestFirstFiltering and assert the endpoint returns without error and excludes the V0 row. Two reviewers converged on this independently in R2.
  2. DEREM-5 (P3, panel-deferred): The whitespace divergence between cycle and edit paths needs a human decision: file a ticket for the appendText alignment, or explicitly accept the gap.

🤖 This review was automatically generated with Coder Agents.

@ibetitsmike ibetitsmike force-pushed the codagt-319-prompts-endpoint branch from e96f371 to 648c5d1 Compare May 11, 2026 15:41
Copy link
Copy Markdown
Collaborator Author

R2 follow-ups on 648c5d1f:

  • DEREM-10 (no regression test for V0 guard): added a raw V0 user message (content_version = 0, content = scalar JSON string "plain text from V0") to NewestFirstFiltering. Verified locally that removing AND jsonb_typeof(cm.content) = 'array' from the generated query makes the test fail, so a future deletion of the guard is caught.
  • DEREM-5 (whitespace divergence vs appendText): filed chat: align prompt cycle and edit path whitespace handling #25125 to track the client edit-path alignment. The SQL behavior in this PR is intentional (boundary whitespace can be deliberate); the client-side appendText is the one that should follow up.

/coder-agents-review

Copy link
Copy Markdown
Contributor

@coder-agents-review coder-agents-review Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All findings resolved. 8 fixed across two commits (ab2a893, 648c5d1), 1 deferred with ticket (#25125), 1 dropped. Netero and 3-reviewer panel verified all fixes in R4. No new findings across R2-R4.

Four rounds, 10 findings, clean exit. The partial index, V0 scalar guard, authorization model, cache invalidation strategy, and test suite are solid.

🤖 This review was automatically generated with Coder Agents.

@ibetitsmike ibetitsmike force-pushed the codagt-319-prompts-endpoint branch from 648c5d1 to 93b6f45 Compare May 11, 2026 17:16
Copy link
Copy Markdown
Collaborator Author

Came back and addressed DEREM-5 within this PR. 93b6f450 makes the client getEditableUserMessagePayload concatenate user-message text parts verbatim, mirroring the SQL string_agg in GetChatUserPromptsByChatID. The shared appendText helper used for assistant streaming is left untouched (it intentionally drops whitespace-only chunks there). Added a unit test for the divergence case. Closed #25125 as fixed-here.

/coder-agents-review

Copy link
Copy Markdown
Contributor

@coder-agents-review coder-agents-review Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 10 findings resolved. DEREM-5 (the last open item) fixed in 93b6f45: getEditableUserMessagePayload now concatenates text parts verbatim, matching the SQL string_agg. Unit test added, #25125 closed. Netero and 2-reviewer panel verified the fix with no new findings.

Five rounds, 10 findings, all addressed. Clean.

🤖 This review was automatically generated with Coder Agents.

@ibetitsmike ibetitsmike force-pushed the codagt-319-prompts-endpoint branch from 93b6f45 to f1c2dc3 Compare May 13, 2026 08:10
Copy link
Copy Markdown
Collaborator Author

@ibetitsmike ibetitsmike left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice cleanup after the earlier review. The dedicated endpoint is well-scoped, the legacy content guard and empty-array behavior are covered, and I did not find any blocking issues. Left two small TypeScript consistency nits.

Mux reviewed this on behalf of Mike.

Comment thread site/src/api/api.ts Outdated
Comment thread site/src/pages/AgentsPage/AgentChatPage.stories.tsx
@ibetitsmike ibetitsmike force-pushed the codagt-319-prompts-endpoint branch 2 times, most recently from 6348014 to 0819a5f Compare May 14, 2026 09:17
Comment thread site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts Outdated
Comment thread site/src/pages/AgentsPage/components/ChatPageContent.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts Outdated
Comment thread site/src/api/queries/chats.ts Outdated
Comment thread site/src/api/queries/chats.ts Outdated
Comment thread site/src/api/queries/chats.ts Outdated
Comment thread site/src/api/queries/chats.ts Outdated
The merged CODAGT-319 (#25004) cycles only through messages already
loaded in the in-memory chat store, capped at 50 per page. Long
chats and chats whose oldest turns have rolled out of the page can
no longer recall their full prompt history from the composer's
up/down arrow cycle.

This change adds GET /api/experimental/chats/{chat}/prompts, a
small newest-first projection of user-authored prompts that the
composer pulls into a dedicated React Query. The query is filtered
and ordered server-side, falls within a 1..2000 limit window, and
is backed by a partial index that turns the planner's bitmap+sort
into a presorted index scan.

Frontend: ChatPageContent.tsx now reads the cycle source from
useQuery(chatPromptsQuery(chatId)) instead of deriving from the
in-memory store. The composer's existing cycleHistorySnapshotRef
anchors the in-flight cycle so refetches caused by send/edit
mutations cannot shift the indexed prompt out from under the user.
Cache invalidation hooks live in the createChatMessage,
editChatMessage, and useChatStore.upsertCacheMessages paths.
@ibetitsmike ibetitsmike force-pushed the codagt-319-prompts-endpoint branch from 0819a5f to 0149639 Compare May 14, 2026 09:55
Copy link
Copy Markdown
Member

@johnstcn johnstcn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Index LGTM

@ibetitsmike ibetitsmike merged commit cb37047 into main May 14, 2026
31 checks passed
@ibetitsmike ibetitsmike deleted the codagt-319-prompts-endpoint branch May 14, 2026 10:43
@github-actions github-actions Bot locked and limited conversation to collaborators May 14, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants