mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
pr 2 implementation
This commit is contained in:
Generated
+36
@@ -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.",
|
||||
|
||||
Generated
+32
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
+73
-1887
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+311
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user