feat: move chat messages to dedicated /chats/{id}/messages endpoint (#23021)

## Summary

Moves the messages response out of `GET /chats/{id}` and into a
dedicated `GET /chats/{id}/messages` endpoint.

### Backend
- `GET /chats/{id}` now returns just the `Chat` object (no messages)
- `GET /chats/{id}/messages` is a new endpoint returning
`ChatMessagesResponse` with `messages` and `queued_messages`
- Added `ChatMessagesResponse` SDK type and `GetChatMessages` client
method

### Frontend
- `getChat()` API method returns `Chat` instead of `ChatWithMessages`
- Added `getChatMessages()` API method for the new endpoint
- Split `chatQuery` into two: `chatQuery` (metadata) and
`chatMessagesQuery` (messages)
- Updated all cache mutations, optimistic updates, and websocket
handlers
- Updated tests and stories

### Files changed
| File | Change |
|---|---|
| `coderd/coderd.go` | Register `GET /messages` route |
| `coderd/chats.go` | Simplify `getChat`, add `getChatMessages` handler
|
| `codersdk/chats.go` | New type + method, update `GetChat` return |
| `site/src/api/api.ts` | New method, update `getChat` |
| `site/src/api/queries/chats.ts` | New query, update cache mutations |
| `site/src/pages/AgentsPage/AgentDetail.tsx` | Use separate queries |
| `site/src/pages/AgentsPage/AgentDetail/ChatContext.ts` | Update types
and cache writes |
| `site/src/pages/AgentsPage/AgentsPage.tsx` | Update websocket cache
handler |
This commit is contained in:
Kyle Carberry
2026-03-13 08:35:46 -04:00
committed by GitHub
parent 0e7e0a959e
commit 690e3a87d8
15 changed files with 312 additions and 334 deletions
+24 -18
View File
@@ -176,7 +176,7 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
if getErr != nil {
return false
}
if got.Chat.Status != codersdk.ChatStatusWaiting && got.Chat.Status != codersdk.ChatStatusError {
if got.Status != codersdk.ChatStatusWaiting && got.Status != codersdk.ChatStatusError {
return false
}
// Also ensure the subagent LLM call has been made.
@@ -1055,32 +1055,35 @@ func TestCreateWorkspaceTool_EndToEnd(t *testing.T) {
})
require.NoError(t, err)
var chatWithMessages codersdk.ChatWithMessages
var chatResult codersdk.Chat
require.Eventually(t, func() bool {
got, getErr := client.GetChat(ctx, chat.ID)
if getErr != nil {
return false
}
chatWithMessages = got
return got.Chat.Status == codersdk.ChatStatusWaiting || got.Chat.Status == codersdk.ChatStatusError
chatResult = got
return got.Status == codersdk.ChatStatusWaiting || got.Status == codersdk.ChatStatusError
}, testutil.WaitLong, testutil.IntervalFast)
if chatWithMessages.Chat.Status == codersdk.ChatStatusError {
if chatResult.Status == codersdk.ChatStatusError {
lastError := ""
if chatWithMessages.Chat.LastError != nil {
lastError = *chatWithMessages.Chat.LastError
if chatResult.LastError != nil {
lastError = *chatResult.LastError
}
require.FailNowf(t, "chat run failed", "last_error=%q", lastError)
}
require.NotNil(t, chatWithMessages.Chat.WorkspaceID)
workspaceID := *chatWithMessages.Chat.WorkspaceID
require.NotNil(t, chatResult.WorkspaceID)
workspaceID := *chatResult.WorkspaceID
workspace, err := client.Workspace(ctx, workspaceID)
require.NoError(t, err)
require.Equal(t, workspaceName, workspace.Name)
chatMsgs, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
var foundCreateWorkspaceResult bool
for _, message := range chatWithMessages.Messages {
for _, message := range chatMsgs.Messages {
if message.Role != "tool" {
continue
}
@@ -1223,33 +1226,36 @@ func TestStartWorkspaceTool_EndToEnd(t *testing.T) {
})
require.NoError(t, err)
var chatWithMessages codersdk.ChatWithMessages
var chatResult codersdk.Chat
require.Eventually(t, func() bool {
got, getErr := client.GetChat(ctx, chat.ID)
if getErr != nil {
return false
}
chatWithMessages = got
return got.Chat.Status == codersdk.ChatStatusWaiting || got.Chat.Status == codersdk.ChatStatusError
chatResult = got
return got.Status == codersdk.ChatStatusWaiting || got.Status == codersdk.ChatStatusError
}, testutil.WaitSuperLong, testutil.IntervalFast)
if chatWithMessages.Chat.Status == codersdk.ChatStatusError {
if chatResult.Status == codersdk.ChatStatusError {
lastError := ""
if chatWithMessages.Chat.LastError != nil {
lastError = *chatWithMessages.Chat.LastError
if chatResult.LastError != nil {
lastError = *chatResult.LastError
}
require.FailNowf(t, "chat run failed", "last_error=%q", lastError)
}
// Verify the workspace was started.
require.NotNil(t, chatWithMessages.Chat.WorkspaceID)
require.NotNil(t, chatResult.WorkspaceID)
updatedWorkspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Equal(t, codersdk.WorkspaceTransitionStart, updatedWorkspace.LatestBuild.Transition)
chatMsgs, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
// Verify start_workspace tool result exists in the chat messages.
var foundStartWorkspaceResult bool
for _, message := range chatWithMessages.Messages {
for _, message := range chatMsgs.Messages {
if message.Role != "tool" {
continue
}
+13 -9
View File
@@ -92,17 +92,19 @@ func TestAnthropicWebSearchRoundTrip(t *testing.T) {
// Verify the chat completed and messages were persisted.
chatData, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
chatMsgs, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
t.Logf("Chat status after step 1: %s, messages: %d",
chatData.Chat.Status, len(chatData.Messages))
logMessages(t, chatData.Messages)
chatData.Status, len(chatMsgs.Messages))
logMessages(t, chatMsgs.Messages)
require.Equal(t, codersdk.ChatStatusWaiting, chatData.Chat.Status,
require.Equal(t, codersdk.ChatStatusWaiting, chatData.Status,
"chat should be in waiting status after step 1")
// Find the first assistant message and verify it has the
// content parts the UI needs to render web search results:
// tool-call(PE), source, tool-result(PE), and text.
assistantMsg := findAssistantWithText(t, chatData.Messages)
assistantMsg := findAssistantWithText(t, chatMsgs.Messages)
require.NotNil(t, assistantMsg,
"expected an assistant message with text content after step 1")
@@ -152,17 +154,19 @@ func TestAnthropicWebSearchRoundTrip(t *testing.T) {
// Verify the follow-up completed and produced content.
chatData2, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
chatMsgs2, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
t.Logf("Chat status after step 2: %s, messages: %d",
chatData2.Chat.Status, len(chatData2.Messages))
logMessages(t, chatData2.Messages)
chatData2.Status, len(chatMsgs2.Messages))
logMessages(t, chatMsgs2.Messages)
require.Equal(t, codersdk.ChatStatusWaiting, chatData2.Chat.Status,
require.Equal(t, codersdk.ChatStatusWaiting, chatData2.Status,
"chat should be in waiting status after step 2")
require.Greater(t, len(chatData2.Messages), len(chatData.Messages),
require.Greater(t, len(chatMsgs2.Messages), len(chatMsgs.Messages),
"follow-up should have added more messages")
// The last assistant message should have text.
lastAssistant := findLastAssistantWithText(t, chatData2.Messages)
lastAssistant := findLastAssistantWithText(t, chatMsgs2.Messages)
require.NotNil(t, lastAssistant,
"expected an assistant message with text in the follow-up")
+10 -2
View File
@@ -360,6 +360,15 @@ func (api *API) listChatModels(rw http.ResponseWriter, r *http.Request) {
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
httpapi.Write(ctx, rw, http.StatusOK, convertChat(chat, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatMessages(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
@@ -385,8 +394,7 @@ func (api *API) getChat(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatWithMessages{
Chat: convertChat(chat, nil),
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatMessagesResponse{
Messages: convertChatMessages(messages),
QueuedMessages: convertChatQueuedMessages(queuedMessages),
})
+54 -50
View File
@@ -85,12 +85,14 @@ func TestPostChats(t *testing.T) {
require.NotNil(t, chat.RootChatID)
require.Equal(t, chat.ID, *chat.RootChatID)
chatWithMessages, err := client.GetChat(ctx, chat.ID)
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, chat.ID, chatWithMessages.Chat.ID)
messagesResult, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, chat.ID, chatResult.ID)
foundUserMessage := false
for _, message := range chatWithMessages.Messages {
for _, message := range messagesResult.Messages {
if message.Role != "user" {
continue
}
@@ -123,9 +125,9 @@ func TestPostChats(t *testing.T) {
})
require.NoError(t, err)
chatWithMessages, err := client.GetChat(ctx, chat.ID)
messagesResult, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
for _, message := range chatWithMessages.Messages {
for _, message := range messagesResult.Messages {
require.NotEqual(t, "system", message.Role)
}
})
@@ -1322,19 +1324,21 @@ func TestGetChat(t *testing.T) {
})
require.NoError(t, err)
chatWithMessages, err := client.GetChat(ctx, createdChat.ID)
chatResult, err := client.GetChat(ctx, createdChat.ID)
require.NoError(t, err)
require.Equal(t, createdChat.ID, chatWithMessages.Chat.ID)
require.Equal(t, firstUser.UserID, chatWithMessages.Chat.OwnerID)
require.Equal(t, modelConfig.ID, chatWithMessages.Chat.LastModelConfigID)
require.Equal(t, "get chat route payload", chatWithMessages.Chat.Title)
require.NotZero(t, chatWithMessages.Chat.CreatedAt)
require.NotZero(t, chatWithMessages.Chat.UpdatedAt)
require.NotEmpty(t, chatWithMessages.Messages)
require.Empty(t, chatWithMessages.QueuedMessages)
messagesResult, err := client.GetChatMessages(ctx, createdChat.ID)
require.NoError(t, err)
require.Equal(t, createdChat.ID, chatResult.ID)
require.Equal(t, firstUser.UserID, chatResult.OwnerID)
require.Equal(t, modelConfig.ID, chatResult.LastModelConfigID)
require.Equal(t, "get chat route payload", chatResult.Title)
require.NotZero(t, chatResult.CreatedAt)
require.NotZero(t, chatResult.UpdatedAt)
require.NotEmpty(t, messagesResult.Messages)
require.Empty(t, messagesResult.QueuedMessages)
foundUserMessage := false
for _, message := range chatWithMessages.Messages {
for _, message := range messagesResult.Messages {
require.Equal(t, createdChat.ID, message.ChatID)
require.NotEqual(t, "system", message.Role)
for _, part := range message.Content {
@@ -1646,19 +1650,19 @@ func TestPostChatMessages(t *testing.T) {
require.True(t, hasTextPart(created.QueuedMessage.Content, messageText))
require.Eventually(t, func() bool {
chatWithMessages, getErr := client.GetChat(ctx, chat.ID)
messagesResult, getErr := client.GetChatMessages(ctx, chat.ID)
if getErr != nil {
return false
}
for _, queued := range chatWithMessages.QueuedMessages {
for _, queued := range messagesResult.QueuedMessages {
if queued.ID == created.QueuedMessage.ID &&
queued.ChatID == chat.ID &&
hasTextPart(queued.Content, messageText) {
return true
}
}
for _, message := range chatWithMessages.Messages {
for _, message := range messagesResult.Messages {
if message.Role == "user" && hasTextPart(message.Content, messageText) {
return true
}
@@ -1674,11 +1678,11 @@ func TestPostChatMessages(t *testing.T) {
require.True(t, hasTextPart(created.Message.Content, messageText))
require.Eventually(t, func() bool {
chatWithMessages, getErr := client.GetChat(ctx, chat.ID)
messagesResult, getErr := client.GetChatMessages(ctx, chat.ID)
if getErr != nil {
return false
}
for _, message := range chatWithMessages.Messages {
for _, message := range messagesResult.Messages {
if message.ID == created.Message.ID &&
message.Role == "user" &&
hasTextPart(message.Content, messageText) {
@@ -1784,11 +1788,11 @@ func TestChatMessageWithFileReferences(t *testing.T) {
var found bool
require.Eventually(t, func() bool {
chatWithMessages, getErr := client.GetChat(ctx, chat.ID)
messagesResult, getErr := client.GetChatMessages(ctx, chat.ID)
if getErr != nil {
return false
}
for _, message := range chatWithMessages.Messages {
for _, message := range messagesResult.Messages {
if message.Role != "user" {
continue
}
@@ -1802,7 +1806,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
}
// The message may have been queued.
if created.Queued && created.QueuedMessage != nil {
for _, queued := range chatWithMessages.QueuedMessages {
for _, queued := range messagesResult.QueuedMessages {
for _, part := range queued.Content {
if part.Type == codersdk.ChatMessagePartTypeText &&
part.Text == wantText {
@@ -1842,11 +1846,11 @@ func TestChatMessageWithFileReferences(t *testing.T) {
"```lib/utils.ts\nconst x = 1;\n```"
require.Eventually(t, func() bool {
chatWithMessages, getErr := client.GetChat(ctx, chat.ID)
messagesResult, getErr := client.GetChatMessages(ctx, chat.ID)
if getErr != nil {
return false
}
for _, msg := range chatWithMessages.Messages {
for _, msg := range messagesResult.Messages {
for _, part := range msg.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
return true
@@ -1854,7 +1858,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
}
}
if created.Queued && created.QueuedMessage != nil {
for _, queued := range chatWithMessages.QueuedMessages {
for _, queued := range messagesResult.QueuedMessages {
for _, part := range queued.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
return true
@@ -1889,11 +1893,11 @@ func TestChatMessageWithFileReferences(t *testing.T) {
// No fenced code block when content is empty.
wantText := "[file-reference] README.md:1"
require.Eventually(t, func() bool {
chatWithMessages, getErr := client.GetChat(ctx, chat.ID)
messagesResult, getErr := client.GetChatMessages(ctx, chat.ID)
if getErr != nil {
return false
}
for _, msg := range chatWithMessages.Messages {
for _, msg := range messagesResult.Messages {
for _, part := range msg.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
return true
@@ -1901,7 +1905,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
}
}
if created.Queued && created.QueuedMessage != nil {
for _, queued := range chatWithMessages.QueuedMessages {
for _, queued := range messagesResult.QueuedMessages {
for _, part := range queued.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
return true
@@ -1937,11 +1941,11 @@ func TestChatMessageWithFileReferences(t *testing.T) {
"```server.go\nfunc main() {\n\tfmt.Println()\n}\n```"
require.Eventually(t, func() bool {
chatWithMessages, getErr := client.GetChat(ctx, chat.ID)
messagesResult, getErr := client.GetChatMessages(ctx, chat.ID)
if getErr != nil {
return false
}
for _, msg := range chatWithMessages.Messages {
for _, msg := range messagesResult.Messages {
for _, part := range msg.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
return true
@@ -1949,7 +1953,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
}
}
if created.Queued && created.QueuedMessage != nil {
for _, queued := range chatWithMessages.QueuedMessages {
for _, queued := range messagesResult.QueuedMessages {
for _, part := range queued.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
return true
@@ -2017,7 +2021,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
}
require.Eventually(t, func() bool {
chatWithMessages, getErr := client.GetChat(ctx, chat.ID)
messagesResult, getErr := client.GetChatMessages(ctx, chat.ID)
if getErr != nil {
return false
}
@@ -2042,13 +2046,13 @@ func TestChatMessageWithFileReferences(t *testing.T) {
return true
}
for _, msg := range chatWithMessages.Messages {
for _, msg := range messagesResult.Messages {
if msg.Role == "user" && checkParts(msg.Content) {
return true
}
}
if created.Queued && created.QueuedMessage != nil {
for _, queued := range chatWithMessages.QueuedMessages {
for _, queued := range messagesResult.QueuedMessages {
if checkParts(queued.Content) {
return true
}
@@ -2201,9 +2205,9 @@ func TestChatMessageWithFiles(t *testing.T) {
}
// Verify file parts omit inline data in the API response.
chatWithMessages, err := client.GetChat(ctx, chat.ID)
messagesResult, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
for _, msg := range chatWithMessages.Messages {
for _, msg := range messagesResult.Messages {
for _, part := range msg.Content {
if part.Type == codersdk.ChatMessagePartTypeFile {
require.True(t, part.FileID.Valid, "file part should have a valid file_id")
@@ -2297,11 +2301,11 @@ func TestPatchChatMessage(t *testing.T) {
})
require.NoError(t, err)
chatWithMessages, err := client.GetChat(ctx, chat.ID)
messagesResult, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
var userMessageID int64
for _, message := range chatWithMessages.Messages {
for _, message := range messagesResult.Messages {
if message.Role == "user" {
userMessageID = message.ID
break
@@ -2329,11 +2333,11 @@ func TestPatchChatMessage(t *testing.T) {
}
require.True(t, foundEditedText)
updatedChat, err := client.GetChat(ctx, chat.ID)
messagesResult, err = client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
foundEditedInChat := false
foundOriginalInChat := false
for _, message := range updatedChat.Messages {
for _, message := range messagesResult.Messages {
if message.Role != "user" {
continue
}
@@ -2382,11 +2386,11 @@ func TestPatchChatMessage(t *testing.T) {
require.NoError(t, err)
// Find the user message ID.
chatWithMessages, err := client.GetChat(ctx, chat.ID)
messagesResult, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
var userMessageID int64
for _, message := range chatWithMessages.Messages {
for _, message := range messagesResult.Messages {
if message.Role == "user" {
userMessageID = message.ID
break
@@ -2424,12 +2428,12 @@ func TestPatchChatMessage(t *testing.T) {
require.True(t, foundText, "edited message should contain updated text")
require.True(t, foundFile, "edited message should preserve file_id")
// GET the chat and verify the file_id persists.
updatedChat, err := client.GetChat(ctx, chat.ID)
// GET the chat messages and verify the file_id persists.
messagesResult, err = client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
var foundTextInChat, foundFileInChat bool
for _, message := range updatedChat.Messages {
for _, message := range messagesResult.Messages {
if message.Role != "user" {
continue
}
@@ -3037,9 +3041,9 @@ func TestDeleteChatQueuedMessage(t *testing.T) {
res.Body.Close()
require.Equal(t, http.StatusNoContent, res.StatusCode)
chatWithMessages, err := client.GetChat(ctx, chat.ID)
messagesResult, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
for _, queued := range chatWithMessages.QueuedMessages {
for _, queued := range messagesResult.QueuedMessages {
require.NotEqual(t, queuedMessage.ID, queued.ID)
}
@@ -3136,9 +3140,9 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
}
require.True(t, foundPromotedText)
chatWithMessages, err := client.GetChat(ctx, chat.ID)
messagesResult, err := client.GetChatMessages(ctx, chat.ID)
require.NoError(t, err)
for _, queued := range chatWithMessages.QueuedMessages {
for _, queued := range messagesResult.QueuedMessages {
require.NotEqual(t, queuedMessage.ID, queued.ID)
}
+1
View File
@@ -1173,6 +1173,7 @@ func New(options *Options) *API {
r.Get("/git/watch", api.watchChatGit)
r.Post("/archive", api.archiveChat)
r.Post("/unarchive", api.unarchiveChat)
r.Get("/messages", api.getChatMessages)
r.Post("/messages", api.postChatMessages)
r.Patch("/messages/{message}", api.patchChatMessage)
r.Get("/stream", api.streamChat)