pr 2 implementation

This commit is contained in:
Hugo Dutka
2026-05-20 12:22:06 +00:00
parent 5d6ca0a9dd
commit ffbb99e12e
15 changed files with 2111 additions and 3176 deletions
+36
View File
@@ -818,6 +818,42 @@ const docTemplate = `{
]
}
},
"/api/experimental/chats/{chat}/reconcile-invalid": {
"post": {
"description": "Experimental: this endpoint is subject to change.",
"produces": [
"application/json"
],
"tags": [
"Chats"
],
"summary": "Reconcile invalid chat state",
"operationId": "reconcile-invalid-chat-state",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.Chat"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/experimental/chats/{chat}/stream": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
+32
View File
@@ -723,6 +723,38 @@
]
}
},
"/api/experimental/chats/{chat}/reconcile-invalid": {
"post": {
"description": "Experimental: this endpoint is subject to change.",
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Reconcile invalid chat state",
"operationId": "reconcile-invalid-chat-state",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.Chat"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/experimental/chats/{chat}/stream": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
+1
View File
@@ -1331,6 +1331,7 @@ func New(options *Options) *API {
r.Get("/git", api.watchChatGit)
})
r.Post("/interrupt", api.interruptChat)
r.Post("/reconcile-invalid", api.reconcileInvalidChatState)
r.Post("/tool-results", api.postChatToolResults)
r.Post("/title/regenerate", api.regenerateChatTitle)
r.Post("/title/propose", api.proposeChatTitle)
+205 -227
View File
@@ -49,6 +49,7 @@ import (
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/coderd/x/chatfiles"
"github.com/coder/coder/v2/coderd/x/gitsync"
@@ -113,30 +114,6 @@ func maybeWriteLimitErr(ctx context.Context, rw http.ResponseWriter, err error)
return false
}
func publishChatTitleChange(logger slog.Logger, ps dbpubsub.Pubsub, chat database.Chat) {
if ps == nil {
return
}
event := codersdk.ChatWatchEvent{
Kind: codersdk.ChatWatchEventKindTitleChange,
Chat: db2sdk.Chat(chat, nil, nil),
}
payload, err := json.Marshal(event)
if err != nil {
logger.Error(context.Background(), "failed to marshal chat title change event",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
return
}
if err := ps.Publish(pubsub.ChatWatchEventChannel(chat.OwnerID), payload); err != nil {
logger.Error(context.Background(), "failed to publish chat title change event",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
}
}
func publishChatConfigEvent(logger slog.Logger, ps dbpubsub.Pubsub, kind pubsub.ChatConfigEventKind, entityID uuid.UUID) {
payload, err := json.Marshal(pubsub.ChatConfigEvent{
Kind: kind,
@@ -1086,14 +1063,6 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
title := chatTitleFromMessage(titleSource)
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat processor is unavailable.",
Detail: "Chat processor is not configured.",
})
return
}
modelConfigID, modelConfigStatus, modelConfigError := api.resolveCreateChatModelConfigID(ctx, apiKey.UserID, req)
if modelConfigError != nil {
httpapi.Write(ctx, rw, modelConfigStatus, *modelConfigError)
@@ -1304,14 +1273,6 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
func (api *API) listChatModels(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat processor is unavailable.",
Detail: "Chat processor is not configured.",
})
return
}
availability, err := api.getUserChatProviderAvailability(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@@ -2579,35 +2540,7 @@ func (api *API) applyChatTitleUpdate(
return chat, false
}
var (
updatedChat database.Chat
wrote bool
err error
)
if api.chatDaemon != nil {
updatedChat, wrote, err = api.chatDaemon.RenameChatTitle(ctx, chat, trimmedTitle)
} else {
err = api.Database.InTx(func(tx database.Store) error {
currentChat, txErr := tx.GetChatByID(ctx, chat.ID)
if txErr != nil {
return txErr
}
if trimmedTitle == currentChat.Title {
updatedChat = currentChat
wrote = false
return nil
}
updatedChat, txErr = tx.UpdateChatTitleByID(ctx, database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: trimmedTitle,
})
if txErr != nil {
return txErr
}
wrote = true
return nil
}, nil)
}
updatedChat, wrote, err := api.chatDaemon.RenameChatTitle(ctx, chat, trimmedTitle)
if err != nil {
if errors.Is(err, chatd.ErrManualTitleRegenerationInProgress) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
@@ -2626,11 +2559,7 @@ func (api *API) applyChatTitleUpdate(
return chat, true
}
if wrote {
if api.chatDaemon != nil {
api.chatDaemon.PublishTitleChange(updatedChat)
} else {
publishChatTitleChange(api.Logger, api.Pubsub, updatedChat)
}
api.chatDaemon.PublishTitleChange(updatedChat)
}
return updatedChat, false
}
@@ -2727,6 +2656,21 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
if req.Archived != nil {
archived := *req.Archived
// Archive invariant is one-way: parent archived implies
// child archived. Archive state changes target the root
// chat and cascade atomically across the family; child
// chats cannot be archived or unarchived independently.
// This check precedes the no-op check so any child attempt
// surfaces the root-only error regardless of the chat's
// current archived value.
if chat.ParentChatID.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat archive state can only be changed on the root chat.",
})
return
}
if archived == chat.Archived {
state := "archived"
if !archived {
@@ -2738,37 +2682,30 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
return
}
// Archive invariant is one-way: parent archived implies
// child archived. Parent archive/unarchive cascade via
// root_chat_id; individual child archive is permitted;
// child unarchive while the parent is archived is rejected
// (enforced atomically in chatd.Server.UnarchiveChat).
if chat.ParentChatID.Valid && !archived {
if done := api.writeChildUnarchiveGuard(ctx, rw, chat); done {
return
}
}
var err error
// Use chatDaemon when available so it can interrupt active
// processing before broadcasting archive state. Fall back to
// direct DB when no daemon is running.
if archived {
if api.chatDaemon != nil {
err = api.chatDaemon.ArchiveChat(ctx, chat)
} else {
_, err = api.Database.ArchiveChatByID(ctx, chat.ID)
}
err = api.chatDaemon.ArchiveChat(ctx, chat)
} else {
if api.chatDaemon != nil {
err = api.chatDaemon.UnarchiveChat(ctx, chat)
} else {
_, err = api.Database.UnarchiveChatByID(ctx, chat.ID)
}
err = api.chatDaemon.UnarchiveChat(ctx, chat)
}
if err != nil {
if errors.Is(err, chatd.ErrChildUnarchiveParentArchived) {
if errors.Is(err, chatd.ErrArchiveRequiresRootChat) || errors.Is(err, chatstate.ErrChatNotRoot) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot unarchive a child chat while its parent is archived. Unarchive the parent chat to cascade.",
Message: "Chat archive state can only be changed on the root chat.",
})
return
}
if writeChatInvalidState(ctx, rw, err) {
return
}
if errors.Is(err, chatstate.ErrTransitionNotAllowed) {
// Archive only succeeds from idle / error execution
// states (W, E0, E1) per the chatd RFC; active
// chats refuse archive instead of being silently
// transitioned to waiting first.
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot archive an active chat. Interrupt or wait for the chat to finish first.",
Detail: err.Error(),
})
return
}
@@ -2918,36 +2855,17 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusNoContent)
}
// writeChildUnarchiveGuard returns a 400 early when a child unarchive
// request obviously races an archived parent. The durable invariant
// is enforced atomically in chatd.Server.UnarchiveChat; this guard
// just surfaces the error before we take any locks.
//
// writeChatInvalidState writes the shared invalid-state response for
// chatstate.ErrInvalidState across every chat mutation endpoint.
// Returns true when a response has been written.
func (api *API) writeChildUnarchiveGuard(
ctx context.Context,
rw http.ResponseWriter,
chat database.Chat,
) bool {
parent, err := api.Database.GetChatByID(ctx, chat.ParentChatID.UUID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return true
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to load parent chat.",
Detail: err.Error(),
})
return true
func writeChatInvalidState(ctx context.Context, rw http.ResponseWriter, err error) bool {
if !errors.Is(err, chatstate.ErrInvalidState) {
return false
}
if parent.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot unarchive a child chat while its parent is archived. Unarchive the parent chat to cascade.",
})
return true
}
return false
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is in an invalid state.",
})
return true
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -2996,14 +2914,6 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
return
}
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat processor is unavailable.",
Detail: "Chat processor is not configured.",
})
return
}
var req codersdk.CreateChatMessageRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
@@ -3104,10 +3014,15 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
})
return
}
if xerrors.Is(sendErr, chatd.ErrMessageQueueFull) {
if xerrors.Is(sendErr, chatstate.ErrMessageQueueFull) {
var queueFull *chatstate.MessageQueueFullError
detail := ""
if errors.As(sendErr, &queueFull) {
detail = fmt.Sprintf("Maximum %d messages can be queued.", queueFull.Max)
}
httpapi.Write(ctx, rw, http.StatusTooManyRequests, codersdk.Response{
Message: "Message queue is full.",
Detail: fmt.Sprintf("Maximum %d messages can be queued.", chatd.MaxQueueSize),
Detail: detail,
})
return
}
@@ -3117,6 +3032,20 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
})
return
}
if errors.Is(sendErr, chatstate.ErrChatNotFound) {
httpapi.ResourceNotFound(rw)
return
}
if writeChatInvalidState(ctx, rw, sendErr) {
return
}
if errors.Is(sendErr, chatstate.ErrTransitionNotAllowed) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in a state that accepts new messages.",
Detail: sendErr.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat message.",
Detail: sendErr.Error(),
@@ -3187,14 +3116,6 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
return
}
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat processor is unavailable.",
Detail: "Chat processor is not configured.",
})
return
}
messageIDStr := chi.URLParam(r, "message")
messageID, err := strconv.ParseInt(messageIDStr, 10, 64)
if err != nil || messageID <= 0 {
@@ -3254,6 +3175,15 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config ID.",
})
case errors.Is(editErr, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, editErr):
// response already written
case errors.Is(editErr, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in a state that accepts message edits.",
Detail: editErr.Error(),
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to edit chat message.",
@@ -3300,19 +3230,28 @@ func (api *API) deleteChatQueuedMessage(rw http.ResponseWriter, r *http.Request)
return
}
if api.chatDaemon != nil {
err = api.chatDaemon.DeleteQueued(ctx, chatID, queuedMessageID)
} else {
err = api.Database.DeleteChatQueuedMessage(ctx, database.DeleteChatQueuedMessageParams{
ID: queuedMessageID,
ChatID: chatID,
})
}
err = api.chatDaemon.DeleteQueued(ctx, chatID, queuedMessageID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete queued message.",
Detail: err.Error(),
})
switch {
case xerrors.Is(err, chatstate.ErrQueuedMessageNotFound), xerrors.Is(err, sql.ErrNoRows):
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Queued message not found.",
})
case errors.Is(err, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, err):
// response already written
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat has no queued messages to delete.",
Detail: err.Error(),
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete queued message.",
Detail: err.Error(),
})
}
return
}
@@ -3359,14 +3298,6 @@ func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request
return
}
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat processor is unavailable.",
Detail: "Chat processor is not configured.",
})
return
}
_, txErr := api.chatDaemon.PromoteQueued(ctx, chatd.PromoteQueuedOptions{
ChatID: chatID,
CreatedBy: apiKey.UserID,
@@ -3377,16 +3308,30 @@ func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request
if maybeWriteLimitErr(ctx, rw, txErr) {
return
}
if xerrors.Is(txErr, chatd.ErrChatArchived) {
switch {
case xerrors.Is(txErr, chatd.ErrChatArchived):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot promote queued messages in an archived chat.",
})
return
case xerrors.Is(txErr, chatstate.ErrQueuedMessageNotFound):
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Queued message not found.",
})
case errors.Is(txErr, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, txErr):
// response already written
case errors.Is(txErr, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat has no queued messages to promote.",
Detail: txErr.Error(),
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to promote queued message.",
Detail: txErr.Error(),
})
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to promote queued message.",
Detail: txErr.Error(),
})
return
}
@@ -3445,14 +3390,6 @@ func (api *API) streamChat(rw http.ResponseWriter, r *http.Request) {
chatID := chat.ID
logger := api.Logger.Named("chat_streamer").With(slog.F("chat_id", chatID))
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat streaming is not available.",
Detail: "Chat processor is not configured.",
})
return
}
var afterMessageID int64
if v := r.URL.Query().Get("after_id"); v != "" {
var err error
@@ -3469,9 +3406,7 @@ func (api *API) streamChat(rw http.ResponseWriter, r *http.Request) {
// Subscribe before accepting the WebSocket so that failures
// can still be reported as normal HTTP errors.
snapshot, events, cancelSub, ok := api.chatDaemon.SubscribeAuthorized(ctx, chat, r.Header, afterMessageID)
// Subscribe only fails today when the receiver is nil, which
// the chatDaemon == nil guard above already catches. This is
// defensive against future Subscribe failure modes.
// Defensive against future SubscribeAuthorized failure modes.
if !ok {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat streaming is not available.",
@@ -3597,31 +3532,86 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) {
return
}
if api.chatDaemon != nil {
chat = api.chatDaemon.InterruptChat(ctx, chat)
} else {
updatedChat, updateErr := api.Database.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chatID,
Status: database.ChatStatusWaiting,
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: pqtype.NullRawMessage{},
})
if updateErr != nil {
logger.Error(ctx, "failed to mark chat as waiting", slog.Error(updateErr))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to interrupt chat.",
Detail: updateErr.Error(),
})
return
}
chat = updatedChat
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
updated, err := api.chatDaemon.InterruptChat(ctx, chat)
if err != nil {
switch {
case xerrors.Is(err, chatd.ErrChatArchived):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot interrupt an archived chat.",
})
case writeChatInvalidState(ctx, rw, err):
// response already written
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in an interruptible state.",
Detail: err.Error(),
})
default:
logger.Error(ctx, "failed to interrupt chat", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to interrupt chat.",
Detail: err.Error(),
})
}
return
}
chat = updated
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(chat, nil, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Reconcile invalid chat state
// @ID reconcile-invalid-chat-state
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.Chat
// @Router /api/experimental/chats/{chat}/reconcile-invalid [post]
// @Description Experimental: this endpoint is subject to change.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) reconcileInvalidChatState(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
logger := api.Logger.Named("chat_reconcile_invalid").With(slog.F("chat_id", chatID))
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
updated, err := api.chatDaemon.ReconcileInvalidStateChat(ctx, chat)
if err != nil {
switch {
case errors.Is(err, chatstate.ErrChatNotFound), httpapi.Is404Error(err):
httpapi.ResourceNotFound(rw)
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in an invalid state.",
Detail: err.Error(),
})
default:
logger.Error(ctx, "failed to reconcile invalid chat state", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to reconcile chat state.",
Detail: err.Error(),
})
}
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Regenerate chat title
@@ -3654,14 +3644,6 @@ func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) {
return
}
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat processor is unavailable.",
Detail: "Chat processor is not configured.",
})
return
}
updatedChat, err := api.chatDaemon.RegenerateChatTitle(ctx, chat)
if err != nil {
if errors.Is(err, chatd.ErrManualTitleRegenerationInProgress) {
@@ -3707,14 +3689,6 @@ func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) {
return
}
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat processor is unavailable.",
Detail: "Chat processor is not configured.",
})
return
}
title, err := api.chatDaemon.ProposeChatTitle(ctx, chat)
if err != nil {
if errors.Is(err, chatd.ErrManualTitleRegenerationInProgress) {
@@ -7741,15 +7715,10 @@ func (api *API) postChatToolResults(rw http.ResponseWriter, r *http.Request) {
return
}
// Fast-path check outside the transaction. The authoritative
// check happens inside SubmitToolResults under a row lock.
if chat.Status != database.ChatStatusRequiresAction {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not waiting for tool results.",
Detail: fmt.Sprintf("Chat status is %q, expected %q.", chat.Status, database.ChatStatusRequiresAction),
})
return
}
// The authoritative status check happens inside SubmitToolResults
// under the row lock; that path also surfaces the shared
// invalid-state response for chats that are not in a valid
// execution state at all.
var dynamicTools json.RawMessage
if chat.DynamicTools.Valid {
@@ -7781,6 +7750,15 @@ func (api *API) postChatToolResults(rw http.ResponseWriter, r *http.Request) {
Message: validationErr.Message,
Detail: validationErr.Detail,
})
case errors.Is(err, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, err):
// response already written
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not waiting for tool results.",
Detail: err.Error(),
})
default:
api.Logger.Error(ctx, "tool results submission failed",
slog.F("chat_id", chat.ID),
+780
View File
@@ -0,0 +1,780 @@
package coderd_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
// driveChatToWaiting transitions the chat from `running` (its initial
// state per the RFC) to `waiting` by running chatstate.FinishTurn.
// Tests use this when they need to exercise endpoint behavior that
// only succeeds from idle execution states (W, E0).
func driveChatToWaiting(ctx context.Context, t *testing.T, api *coderd.API, chatID uuid.UUID) {
t.Helper()
// chatstate writes through the raw database/pubsub, so we use the
// system-restricted context to bypass dbauthz which is keyed on a
// user identity. This mirrors how chatd itself drives transitions
// from background work.
sysCtx := dbauthz.AsSystemRestricted(ctx) //nolint:gocritic // Test fixture composes chatstate transitions outside a request.
machine := chatstate.NewChatMachine(api.Database, api.Pubsub, chatID, chatstate.Options{})
require.NoError(t, machine.Update(sysCtx, func(tx *chatstate.Tx) error {
_, err := tx.FinishTurn(chatstate.FinishTurnInput{})
return err
}))
}
// driveChatToRequiresAction commits an assistant message with a single
// dynamic tool_call part and then transitions the chat to
// `requires_action`. The tool_call_id returned lets the caller
// assemble a valid SubmitToolResultsRequest.
func driveChatToRequiresAction(
ctx context.Context,
t *testing.T,
api *coderd.API,
chat codersdk.Chat,
toolName string,
) (toolCallID string) {
t.Helper()
sysCtx := dbauthz.AsSystemRestricted(ctx) //nolint:gocritic // Test fixture composes chatstate transitions outside a request.
toolCallID = "call-" + uuid.NewString()
assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("dispatching dynamic tool"),
{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: toolCallID,
ToolName: toolName,
Args: json.RawMessage(`{}`),
},
})
require.NoError(t, err)
machine := chatstate.NewChatMachine(api.Database, api.Pubsub, chat.ID, chatstate.Options{})
require.NoError(t, machine.Update(sysCtx, func(tx *chatstate.Tx) error {
_, err := tx.CommitStep(chatstate.CommitStepInput{
Messages: []chatstate.Message{{
Role: database.ChatMessageRoleAssistant,
Content: assistantContent,
Visibility: database.ChatMessageVisibilityBoth,
ModelConfigID: uuid.NullUUID{UUID: chat.LastModelConfigID, Valid: true},
ContentVersion: chatprompt.CurrentContentVersion,
}},
})
if err != nil {
return err
}
_, err = tx.EnterRequiresAction(chatstate.EnterRequiresActionInput{})
return err
}))
return toolCallID
}
// TestPostChatsStartsRunning verifies the RFC-mandated `running`
// initial status surfaced by the create-chat endpoint.
func TestPostChatsStartsRunning(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
}},
})
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusRunning, chat.Status,
"new chats must start in `running` per chatd RFC")
// Re-reading also reports `running` because the chat row is
// authoritative and no worker has advanced it.
gotChat, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusRunning, gotChat.Status)
require.NotNil(t, api.Pubsub)
}
// TestArchiveChatStateTransitions covers the two RFC-mandated archive
// behaviors at the endpoint contract level: archiving from an idle
// chat (W) succeeds, and archiving from an active chat (R0) returns
// a state conflict and leaves the chat unarchived.
func TestArchiveChatStateTransitions(t *testing.T) {
t.Parallel()
t.Run("IdleSucceeds", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "archive me"}},
})
require.NoError(t, err)
driveChatToWaiting(ctx, t, api, chat.ID)
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: ptrTo(true)})
require.NoError(t, err)
got, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.True(t, got.Archived)
})
t.Run("ActiveChatReturnsConflict", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "no archive"}},
})
require.NoError(t, err)
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: ptrTo(true)})
requireSDKError(t, err, http.StatusConflict)
got, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.False(t, got.Archived, "active chat must remain unarchived after a conflict")
})
}
// TestPostChatMessagesBusyInterrupt verifies that a busy-interrupt
// send returns a queued response and leaves the chat in `interrupting`
// from the endpoint's perspective.
func TestPostChatMessagesBusyInterrupt(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusRunning, chat.Status)
// CreateChat leaves the chat in `running`; an interrupt-style
// follow-up should land it in `interrupting`.
resp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "stop"}},
BusyBehavior: codersdk.ChatBusyBehaviorInterrupt,
})
require.NoError(t, err)
require.True(t, resp.Queued, "busy interrupt must return queued=true")
require.NotNil(t, resp.QueuedMessage)
got, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusInterrupting, got.Status,
"busy interrupt send must land the chat in `interrupting`")
}
// TestDeleteChatQueuedMessageMissingReturns404 covers the new
// chatstate-driven 404 path for missing queued IDs. The chat must
// have at least one queued message so the request is in a state where
// DeleteQueuedMessage is allowed; the looked-up ID then mismatches
// and the endpoint returns 404 instead of a state-conflict 409.
func TestDeleteChatQueuedMessageMissingReturns404(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
// Seed one queued message via the public endpoint (the chat
// starts in R0, so a queue send lands in R1).
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "queued"}},
BusyBehavior: codersdk.ChatBusyBehaviorQueue,
})
require.NoError(t, err)
res, err := client.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999", chat.ID),
nil,
)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
}
// TestDeleteChatQueuedMessageEmptyQueueReturnsConflict covers the
// state-conflict 409 path when the chat has no queued messages.
func TestDeleteChatQueuedMessageEmptyQueueReturnsConflict(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
res, err := client.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999", chat.ID),
nil,
)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusConflict, res.StatusCode)
}
// TestPromoteChatQueuedMessageMissingReturns404 mirrors the delete
// test for the promote endpoint: with a non-empty queue, an unknown
// queued-message ID returns 404 rather than a 409.
func TestPromoteChatQueuedMessageMissingReturns404(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
// Seed one queued message so the promote transition is allowed.
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "queued"}},
BusyBehavior: codersdk.ChatBusyBehaviorQueue,
})
require.NoError(t, err)
res, err := client.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999/promote", chat.ID),
nil,
)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
}
// TestPromoteChatQueuedMessageEmptyQueueReturnsConflict verifies the
// state-conflict 409 path when the chat has no queued messages.
func TestPromoteChatQueuedMessageEmptyQueueReturnsConflict(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
res, err := client.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999/promote", chat.ID),
nil,
)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusConflict, res.StatusCode)
}
// TestInterruptChatIdleReturnsConflict verifies that interrupting an
// idle chat is now rejected. The fixture composes chatstate
// transitions to reach the W state without depending on the
// background worker.
func TestInterruptChatIdleReturnsConflict(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "interrupt me"}},
})
require.NoError(t, err)
driveChatToWaiting(ctx, t, api, chat.ID)
_, err = client.InterruptChat(ctx, chat.ID)
requireSDKError(t, err, http.StatusConflict)
}
// TestSubmitToolResultsWrongStateReturnsConflict covers the wrong
// chat-status response when the chat is not in requires_action.
func TestSubmitToolResultsWrongStateReturnsConflict(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusRunning, chat.Status)
err = client.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{
Results: []codersdk.ToolResult{{
ToolCallID: "unknown-call",
Output: json.RawMessage(`{}`),
}},
})
requireSDKError(t, err, http.StatusConflict)
}
// TestSubmitToolResultsRequiresActionSucceeds drives a chat into
// requires_action with a single dynamic tool call and verifies a
// matching SubmitToolResults call returns 204 with the tool result
// persisted.
func TestSubmitToolResultsRequiresActionSucceeds(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
dynamicTools := []codersdk.DynamicTool{{
Name: "echo",
Description: "test echo tool",
InputSchema: json.RawMessage(`{"type":"object"}`),
}}
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
UnsafeDynamicTools: dynamicTools,
})
require.NoError(t, err)
toolCallID := driveChatToRequiresAction(ctx, t, api, chat, "echo")
err = client.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{
Results: []codersdk.ToolResult{{
ToolCallID: toolCallID,
Output: json.RawMessage(`{"ok":true}`),
}},
})
require.NoError(t, err)
// The tool result must be persisted as a visible tool message.
got, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
foundToolResult := false
for _, msg := range got.Messages {
if msg.Role != codersdk.ChatMessageRoleTool {
continue
}
for _, part := range msg.Content {
if part.Type == codersdk.ChatMessagePartTypeToolResult && part.ToolCallID == toolCallID {
foundToolResult = true
break
}
}
}
require.True(t, foundToolResult, "tool result message must be visible in chat history")
}
// TestPatchChatArchiveChildRejected verifies that PATCH /api/experimental/chats/{child}
// with archived=true returns the root-only error regardless of the
// child's current archived value, and does not change archive state on
// any family member.
func TestPatchChatArchiveChildRejected(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
root, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "root"}},
})
require.NoError(t, err)
driveChatToWaiting(ctx, t, api, root.ID)
// Sibling child A and B; both unarchived.
childA := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child-a",
Status: database.ChatStatusWaiting,
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
})
childB := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child-b",
Status: database.ChatStatusWaiting,
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
})
err = client.UpdateChat(ctx, childA.ID, codersdk.UpdateChatRequest{Archived: ptrTo(true)})
requireSDKError(t, err, http.StatusBadRequest)
for _, id := range []uuid.UUID{root.ID, childA.ID, childB.ID} {
got, gerr := loadChatRow(ctx, db, id)
require.NoError(t, gerr)
require.False(t, got.Archived, "no family member may flip archive state after a rejected child archive")
}
}
// TestPatchChatUnarchiveChildRejected verifies that PATCH /api/experimental/chats/{child}
// with archived=false on an archived family is rejected with the
// root-only error and leaves every family member archived. The child
// already matches the requested value? No, the family is archived;
// we are asking to unarchive a child individually.
func TestPatchChatUnarchiveChildRejected(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
root, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "root"}},
})
require.NoError(t, err)
driveChatToWaiting(ctx, t, api, root.ID)
childA := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child-a",
Status: database.ChatStatusWaiting,
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
})
childB := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child-b",
Status: database.ChatStatusWaiting,
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
})
// Archive the whole family via the root.
err = client.UpdateChat(ctx, root.ID, codersdk.UpdateChatRequest{Archived: ptrTo(true)})
require.NoError(t, err)
for _, id := range []uuid.UUID{root.ID, childA.ID, childB.ID} {
got, gerr := loadChatRow(ctx, db, id)
require.NoError(t, gerr)
require.True(t, got.Archived, "precondition: family archived after root archive")
}
// Unarchiving a child must be rejected.
err = client.UpdateChat(ctx, childA.ID, codersdk.UpdateChatRequest{Archived: ptrTo(false)})
requireSDKError(t, err, http.StatusBadRequest)
for _, id := range []uuid.UUID{root.ID, childA.ID, childB.ID} {
got, gerr := loadChatRow(ctx, db, id)
require.NoError(t, gerr)
require.True(t, got.Archived, "no family member may flip archive state after a rejected child unarchive")
}
}
// TestPatchChatArchiveRootRollsBackWhenChildCannotArchive verifies the
// family-archive atomicity guarantee surfaced through the endpoint:
// when a child is in a state that rejects SetArchived (running here),
// the whole cascade rolls back and no family member changes archive
// state.
func TestPatchChatArchiveRootRollsBackWhenChildCannotArchive(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
root, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "root"}},
})
require.NoError(t, err)
driveChatToWaiting(ctx, t, api, root.ID)
// Child is running (R0) which is NOT archive-eligible.
child := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child",
Status: database.ChatStatusRunning,
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
})
err = client.UpdateChat(ctx, root.ID, codersdk.UpdateChatRequest{Archived: ptrTo(true)})
requireSDKError(t, err, http.StatusConflict)
for _, id := range []uuid.UUID{root.ID, child.ID} {
got, gerr := loadChatRow(ctx, db, id)
require.NoError(t, gerr)
require.False(t, got.Archived, "rolled-back family archive must not leave any member archived")
}
}
// TestPostChatMessagesInvalidStateReturnsSharedResponse drives a chat
// into the chatstate-invalid state (waiting with a queued backlog)
// and asserts the shared invalid-state response. This is the
// representative endpoint required by the review.
func TestPostChatMessagesInvalidStateReturnsSharedResponse(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _, api := newChatClientWithAPIAndDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
// Drive the chat to an invalid combination: status=waiting (W),
// archived=false, and a queued message. ClassifyExecutionState
// returns StateInvalid for (waiting, queue=true).
driveChatToInvalidWaitingWithQueue(ctx, t, api, chat.ID)
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "send"}},
})
sdkErr := requireSDKError(t, err, http.StatusConflict)
require.Equal(t, "Chat is in an invalid state.", sdkErr.Message,
"invalid-state endpoint response uses the shared message")
}
// TestPostChatToolResultsInvalidStateReturnsSharedResponse drives a
// chat into the chatstate-invalid state and asserts that the tool
// results endpoint returns the shared invalid-state response instead
// of the old "Chat is not waiting for tool results." status-conflict
// message. This locks the fix that removes the endpoint fast-path
// and routes invalid chats through the chatstate-backed transaction.
func TestPostChatToolResultsInvalidStateReturnsSharedResponse(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _, api := newChatClientWithAPIAndDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
// Drive the chat to an invalid combination so the tool-results
// endpoint must surface the shared invalid-state response rather
// than the requires_action status conflict.
driveChatToInvalidWaitingWithQueue(ctx, t, api, chat.ID)
err = client.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{
Results: []codersdk.ToolResult{{
ToolCallID: "call-irrelevant",
Output: json.RawMessage(`{}`),
}},
})
sdkErr := requireSDKError(t, err, http.StatusConflict)
require.Equal(t, "Chat is in an invalid state.", sdkErr.Message,
"tool-results invalid-state response uses the shared message")
}
// TestReconcileInvalidChatStateSucceeds drives a chat into the
// chatstate-invalid combination (waiting with a queued backlog) and
// verifies the reconcile endpoint moves it into a valid error state
// while preserving the queued message.
func TestReconcileInvalidChatStateSucceeds(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
// Drive the chat to an invalid combination: status=waiting (W),
// archived=false, with a queued message. ClassifyExecutionState
// returns StateInvalid for (waiting, queue=true).
driveChatToInvalidWaitingWithQueue(ctx, t, api, chat.ID)
reconciled, err := client.ReconcileInvalidChatState(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, chat.ID, reconciled.ID)
require.Equal(t, codersdk.ChatStatusError, reconciled.Status)
// The persisted row must reflect a valid error state with the
// queued message preserved (E1) and a populated last_error.
persisted, err := loadChatRow(ctx, db, chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusError, persisted.Status)
require.False(t, persisted.Archived)
require.True(t, persisted.LastError.Valid)
queueCount, err := db.CountChatQueuedMessages(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
require.Equal(t, int64(1), queueCount, "queued message is preserved by reconcile")
}
// TestReconcileInvalidChatStateNotInvalidReturnsConflict verifies that
// reconciling a chat that is in a valid execution state is rejected
// with a 409 conflict.
func TestReconcileInvalidChatStateNotInvalidReturnsConflict(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
// A freshly created chat starts in the valid running state (R0).
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusRunning, chat.Status)
_, err = client.ReconcileInvalidChatState(ctx, chat.ID)
sdkErr := requireSDKError(t, err, http.StatusConflict)
require.Equal(t, "Chat is not in an invalid state.", sdkErr.Message)
}
// TestReconcileInvalidChatStateNotFound verifies the reconcile
// endpoint returns 404 for a chat that does not exist.
func TestReconcileInvalidChatStateNotFound(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
_, err := client.ReconcileInvalidChatState(ctx, uuid.New())
requireSDKError(t, err, http.StatusNotFound)
}
// loadChatRow reads a chat row directly through dbauthz.AsSystemRestricted
// so endpoint tests can verify side effects without authorization rules
// interfering.
func loadChatRow(ctx context.Context, db database.Store, id uuid.UUID) (database.Chat, error) {
sysCtx := dbauthz.AsSystemRestricted(ctx) //nolint:gocritic // Test fixture reads chat rows.
return db.GetChatByID(sysCtx, id)
}
// driveChatToInvalidWaitingWithQueue forces a chat into the
// chatstate-invalid combination (status=waiting, archived=false,
// queue non-empty) by writing directly through the database. This is
// an intentional invalid fixture: chatstate transitions reject
// driving toward this combination, and ChatMachine.Update must not
// be composed inside a caller-owned transaction because it owns its
// own transaction lifecycle.
func driveChatToInvalidWaitingWithQueue(
ctx context.Context,
t *testing.T,
api *coderd.API,
chatID uuid.UUID,
) {
t.Helper()
sysCtx := dbauthz.AsSystemRestricted(ctx) //nolint:gocritic // Test fixture writes invalid combination by design.
// Seed the queue with one row attributed to the chat owner. The
// content is a minimal valid JSON payload; only the row's
// presence matters for ClassifyExecutionState. The owner_id is
// filled from the chat row by the SQL.
rawContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("queued"),
})
require.NoError(t, err)
_, err = api.Database.InsertChatQueuedMessage(sysCtx, database.InsertChatQueuedMessageParams{
ChatID: chatID,
Content: rawContent.RawMessage,
ModelConfigID: uuid.NullUUID{},
})
require.NoError(t, err)
// Flip the chat's status to waiting via a raw execution-state
// update. This bypasses the transition matrix to produce the
// (waiting, queued) invalid pairing.
_, err = api.Database.UpdateChatExecutionState(sysCtx, database.UpdateChatExecutionStateParams{
ID: chatID,
Status: database.ChatStatusWaiting,
Archived: false,
})
require.NoError(t, err)
}
// ptrTo returns a pointer to the given value. Helper for the
// pointer-shaped UpdateChatRequest fields.
func ptrTo[T any](v T) *T { return &v }
+9
View File
@@ -14644,6 +14644,15 @@ func TestChatReadOnlySharedWriteHandlers(t *testing.T) {
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("ReconcileInvalidChatState", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
_, err := sharedClient.ReconcileInvalidChatState(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("RegenerateChatTitle", func(t *testing.T) {
t.Parallel()
+532 -1009
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -40,6 +40,14 @@ import (
"github.com/coder/quartz"
)
func TestNewRequiresPubsub(t *testing.T) {
t.Parallel()
require.PanicsWithValue(t, "chatd: Pubsub is nil", func() {
_ = New(Config{})
})
}
type testAgentTool struct {
info fantasy.ToolInfo
providerOptions fantasy.ProviderOptions
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
package chatd
import (
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
)
// newChatMachine constructs a chat-scoped state machine handle bound to
// the server's database and pubsub.
func (p *Server) newChatMachine(chatID uuid.UUID) *chatstate.ChatMachine {
return chatstate.NewChatMachine(p.db, p.pubsub, chatID, chatstate.Options{})
}
// systemMessage builds a chatstate.Message representing a system
// prompt entry for the initial-history slice of CreateChat.
func systemMessage(rawContent pqtype.NullRawMessage, modelConfigID uuid.UUID) chatstate.Message {
return chatstate.Message{
Role: database.ChatMessageRoleSystem,
Content: rawContent,
Visibility: database.ChatMessageVisibilityModel,
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil},
ContentVersion: chatprompt.CurrentContentVersion,
}
}
// userMessage builds a chatstate.Message representing a user message
// for CreateChat, SendMessage, or EditMessage.
func userMessage(rawContent pqtype.NullRawMessage, modelConfigID, createdBy uuid.UUID) chatstate.Message {
return chatstate.Message{
Role: database.ChatMessageRoleUser,
Content: rawContent,
Visibility: database.ChatMessageVisibilityBoth,
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil},
CreatedBy: uuid.NullUUID{UUID: createdBy, Valid: createdBy != uuid.Nil},
ContentVersion: chatprompt.CurrentContentVersion,
}
}
// busyBehaviorToChatState converts the public busy-behavior enum used
// by the server API to the chatstate variant.
func busyBehaviorToChatState(b SendMessageBusyBehavior) chatstate.BusyBehavior {
switch b {
case SendMessageBusyBehaviorInterrupt:
return chatstate.BusyBehaviorInterrupt
default:
return chatstate.BusyBehaviorQueue
}
}
+34 -31
View File
@@ -1278,34 +1278,29 @@ func (p *Server) awaitSubagentCompletion(
timer := p.clock.NewTimer(timeout, "chatd", "subagent_await")
defer timer.Stop()
// When pubsub is available, subscribe for fast status
// notifications and use a less aggressive fallback poll.
// Without pubsub (single-instance / in-memory) fall back
// to the original 200ms polling.
pollInterval := subagentAwaitPollInterval
var notifyCh <-chan struct{}
if p.pubsub != nil {
pollInterval = subagentAwaitFallbackPoll
ch := make(chan struct{}, 1)
notifyCh = ch
cancel, subErr := p.pubsub.SubscribeWithErr(
coderdpubsub.ChatStreamNotifyChannel(targetChatID),
func(_ context.Context, _ []byte, _ error) {
// Non-blocking send so we never stall the
// pubsub dispatch goroutine.
select {
case ch <- struct{}{}:
default:
}
},
)
if subErr == nil {
defer cancel()
} else {
// Subscription failed; fall back to fast polling.
pollInterval = subagentAwaitPollInterval
notifyCh = nil
}
// Subscribe for fast status notifications and use a less
// aggressive fallback poll. If subscription fails, fall back to
// the original 200ms polling.
pollInterval := subagentAwaitFallbackPoll
ch := make(chan struct{}, 1)
notifyCh := (<-chan struct{})(ch)
cancel, subErr := p.pubsub.SubscribeWithErr(
coderdpubsub.ChatStreamNotifyChannel(targetChatID),
func(_ context.Context, _ []byte, _ error) {
// Non-blocking send so we never stall the
// pubsub dispatch goroutine.
select {
case ch <- struct{}{}:
default:
}
},
)
if subErr == nil {
defer cancel()
} else {
// Subscription failed; fall back to fast polling.
pollInterval = subagentAwaitPollInterval
notifyCh = nil
}
ticker := p.clock.NewTicker(pollInterval, "chatd", "subagent_poll")
@@ -1369,10 +1364,18 @@ func (p *Server) closeSubagent(
return targetChat, nil
}
updatedChat := p.InterruptChat(ctx, targetChat)
if updatedChat.Status != database.ChatStatusWaiting {
return database.Chat{}, xerrors.New("set target chat waiting")
updatedChat, err := p.InterruptChat(ctx, targetChat)
if err != nil {
// Idle / archived chats no longer satisfy the
// chatstate.Interrupt precondition. Surface the error
// so the caller can decide whether the parent expected
// the subagent to already be waiting.
return database.Chat{}, xerrors.Errorf("interrupt subagent chat: %w", err)
}
// chatstate.Interrupt lands active runs in `interrupting`
// and requires-action chats in `running`. Workers finalize
// the transition; accept either non-active status as long as
// the transition committed.
return updatedChat, nil
}
+17 -18
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"sync"
"testing"
"time"
@@ -139,6 +140,18 @@ func newInternalTestServerWithLoggerAndClock(
return server
}
type subscribeFailingPubsub struct {
pubsub.Pubsub
}
func (subscribeFailingPubsub) Subscribe(_ string, _ pubsub.Listener) (func(), error) {
return nil, errors.New("subscribe disabled")
}
func (subscribeFailingPubsub) SubscribeWithErr(_ string, _ pubsub.ListenerWithErr) (func(), error) {
return nil, errors.New("subscribe disabled")
}
type subagentTestLogSink struct {
mu sync.Mutex
entries []slog.SinkEntry
@@ -3177,11 +3190,12 @@ func TestAwaitSubagentCompletion(t *testing.T) {
t.Run("CompletesViaPoll", func(t *testing.T) {
t.Parallel()
// Use nil pubsub so awaitSubagentCompletion falls back to
// the fast 200ms poll interval.
// Force subscription failure so awaitSubagentCompletion
// falls back to the fast 200ms poll interval.
db, _ := dbtestutil.NewDB(t)
mClock := quartz.NewMock(t)
server := newInternalTestServerWithClock(t, db, nil, chatprovider.ProviderAPIKeys{}, mClock)
ps := subscribeFailingPubsub{Pubsub: pubsub.NewInMemory()}
server := newInternalTestServerWithClock(t, db, ps, chatprovider.ProviderAPIKeys{}, mClock)
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
@@ -3378,21 +3392,6 @@ func TestAwaitSubagentCompletion(t *testing.T) {
parent, child := createParentChildChats(ctx, t, server, user, org, model)
// signalWake from CreateChat triggers background
// processing. drainInflight waits for in-flight goroutines
// but can't guarantee a pending DB row has been acquired
// yet — the child chat may still be pending if the second
// wake signal hasn't been consumed. Poll until the child
// reaches a terminal DB state so processChat has fully
// finished, then reset to running for the cancellation
// test.
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
c, err := db.GetChatByID(ctx, child.ID)
if err != nil {
return false
}
return c.Status != database.ChatStatusPending && c.Status != database.ChatStatusRunning
}, testutil.IntervalFast)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "")
// Use a short-lived context instead of goroutine + sleep.
shortCtx, cancel := context.WithTimeout(ctx, testutil.IntervalMedium)
+4 -4
View File
@@ -24,7 +24,7 @@ import (
func TestUpdateLastTurnSummaryRejectsStaleWrites(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
owner := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
@@ -66,7 +66,7 @@ func TestUpdateLastTurnSummaryRejectsStaleWrites(t *testing.T) {
require.NoError(t, err)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
server := &Server{db: db}
server := &Server{db: db, pubsub: ps}
server.updateLastTurnSummary(ctx, chat, chat.UpdatedAt, "fresh summary", logger)
fetched, err := db.GetChatByID(ctx, chat.ID)
@@ -92,7 +92,7 @@ func TestUpdateLastTurnSummaryRejectsStaleWrites(t *testing.T) {
func TestPendingChatPersistsSummaryButSkipsWebPush(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
owner := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
@@ -150,7 +150,7 @@ func TestPendingChatPersistsSummaryButSkipsWebPush(t *testing.T) {
dispatcher := &recordingWebpushDispatcher{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
server := &Server{db: db, webpushDispatcher: dispatcher}
server := &Server{db: db, pubsub: ps, webpushDispatcher: dispatcher}
server.maybeFinalizeTurnStatusLabelAndPush(
context.WithoutCancel(ctx),
chat,
+17
View File
@@ -93,6 +93,7 @@ const (
ChatStatusCompleted ChatStatus = "completed"
ChatStatusError ChatStatus = "error"
ChatStatusRequiresAction ChatStatus = "requires_action"
ChatStatusInterrupting ChatStatus = "interrupting"
)
// ChatClientType indicates whether a chat was created from the
@@ -3222,6 +3223,22 @@ func (c *ExperimentalClient) InterruptChat(ctx context.Context, chatID uuid.UUID
return chat, json.NewDecoder(res.Body).Decode(&chat)
}
// ReconcileInvalidChatState recovers a chat stuck in an invalid
// execution state, moving it into an error state from which the caller
// can send a new message or edit history to continue.
func (c *ExperimentalClient) ReconcileInvalidChatState(ctx context.Context, chatID uuid.UUID) (Chat, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/reconcile-invalid", chatID), nil)
if err != nil {
return Chat{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Chat{}, ReadBodyAsError(res)
}
var chat Chat
return chat, json.NewDecoder(res.Body).Decode(&chat)
}
// RegenerateChatTitle requests the server to regenerate the chat's
// title using richer conversation context.
func (c *ExperimentalClient) RegenerateChatTitle(ctx context.Context, chatID uuid.UUID) (Chat, error) {
+311
View File
@@ -2270,6 +2270,317 @@ message in the chat.
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Reconcile invalid chat state
### Code samples
```shell
# Example request using curl
curl -X POST http://coder-server:8080/api/experimental/chats/{chat}/reconcile-invalid \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`POST /api/experimental/chats/{chat}/reconcile-invalid`
Experimental: this endpoint is subject to change.
### Parameters
| Name | In | Type | Required | Description |
|--------|------|--------------|----------|-------------|
| `chat` | path | string(uuid) | true | Chat ID |
### Example responses
> 200 Response
```json
{
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
"archived": true,
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
"children": [
{
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
"archived": true,
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
"children": [],
"client_type": "ui",
"created_at": "2019-08-24T14:15:22Z",
"diff_status": {
"additions": 0,
"approved": true,
"author_avatar_url": "string",
"author_login": "string",
"base_branch": "string",
"changed_files": 0,
"changes_requested": true,
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
"commits": 0,
"deletions": 0,
"head_branch": "string",
"pr_number": 0,
"pull_request_draft": true,
"pull_request_state": "string",
"pull_request_title": "string",
"refreshed_at": "2019-08-24T14:15:22Z",
"reviewer_count": 0,
"stale_at": "2019-08-24T14:15:22Z",
"url": "string"
},
"files": [
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"mime_type": "string",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05"
}
],
"has_unread": true,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"labels": {
"property1": "string",
"property2": "string"
},
"last_error": {
"detail": "string",
"kind": "generic",
"message": "string",
"provider": "string",
"retryable": true,
"status_code": 0
},
"last_injected_context": [
{
"args": [
0
],
"args_delta": "string",
"completed_at": "2019-08-24T14:15:22Z",
"content": "string",
"context_file_agent_id": {
"uuid": "string",
"valid": true
},
"context_file_content": "string",
"context_file_directory": "string",
"context_file_os": "string",
"context_file_path": "string",
"context_file_skill_meta_file": "string",
"context_file_truncated": true,
"created_at": "2019-08-24T14:15:22Z",
"data": [
0
],
"end_line": 0,
"file_id": {
"uuid": "string",
"valid": true
},
"file_name": "string",
"is_error": true,
"is_media": true,
"mcp_server_config_id": {
"uuid": "string",
"valid": true
},
"media_type": "string",
"name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true,
"provider_metadata": [
0
],
"result": [
0
],
"result_delta": "string",
"result_reset": true,
"signature": "string",
"skill_description": "string",
"skill_dir": "string",
"skill_name": "string",
"source_id": "string",
"start_line": 0,
"text": "string",
"title": "string",
"tool_call_id": "string",
"tool_name": "string",
"type": "text",
"url": "string"
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
"last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
"root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7",
"status": "waiting",
"title": "string",
"updated_at": "2019-08-24T14:15:22Z",
"warnings": [
"string"
],
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9"
}
],
"client_type": "ui",
"created_at": "2019-08-24T14:15:22Z",
"diff_status": {
"additions": 0,
"approved": true,
"author_avatar_url": "string",
"author_login": "string",
"base_branch": "string",
"changed_files": 0,
"changes_requested": true,
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
"commits": 0,
"deletions": 0,
"head_branch": "string",
"pr_number": 0,
"pull_request_draft": true,
"pull_request_state": "string",
"pull_request_title": "string",
"refreshed_at": "2019-08-24T14:15:22Z",
"reviewer_count": 0,
"stale_at": "2019-08-24T14:15:22Z",
"url": "string"
},
"files": [
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"mime_type": "string",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05"
}
],
"has_unread": true,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"labels": {
"property1": "string",
"property2": "string"
},
"last_error": {
"detail": "string",
"kind": "generic",
"message": "string",
"provider": "string",
"retryable": true,
"status_code": 0
},
"last_injected_context": [
{
"args": [
0
],
"args_delta": "string",
"completed_at": "2019-08-24T14:15:22Z",
"content": "string",
"context_file_agent_id": {
"uuid": "string",
"valid": true
},
"context_file_content": "string",
"context_file_directory": "string",
"context_file_os": "string",
"context_file_path": "string",
"context_file_skill_meta_file": "string",
"context_file_truncated": true,
"created_at": "2019-08-24T14:15:22Z",
"data": [
0
],
"end_line": 0,
"file_id": {
"uuid": "string",
"valid": true
},
"file_name": "string",
"is_error": true,
"is_media": true,
"mcp_server_config_id": {
"uuid": "string",
"valid": true
},
"media_type": "string",
"name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true,
"provider_metadata": [
0
],
"result": [
0
],
"result_delta": "string",
"result_reset": true,
"signature": "string",
"skill_description": "string",
"skill_dir": "string",
"skill_name": "string",
"source_id": "string",
"start_line": 0,
"text": "string",
"title": "string",
"tool_call_id": "string",
"tool_name": "string",
"type": "text",
"url": "string"
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
"last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
"root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7",
"status": "waiting",
"title": "string",
"updated_at": "2019-08-24T14:15:22Z",
"warnings": [
"string"
],
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9"
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Stream chat events via WebSockets
### Code samples