coderd/exp_chats_queue_edit_test.go
Rama main · réplica de lectura del repositorio original.
package coderd_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"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/rbac"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/websocket"
)
func queuedTextContent(t *testing.T, text string) json.RawMessage {
t.Helper()
raw, err := json.Marshal([]codersdk.ChatMessagePart{codersdk.ChatMessageText(text)})
require.NoError(t, err)
return raw
}
const pausedArchiveRefusal = "Cannot archive: a chat in this family is paused at a queued message under edit. Finish editing, send, or remove that message first."
// TestPatchChatQueuedMessage covers routing, authorization, and error
// mapping for the queued-edit endpoint; state outcomes are covered in
// chatstate.
func TestPatchChatQueuedMessage(t *testing.T) {
t.Parallel()
t.Run("EditSaveResumeDelete", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
sysCtx := dbauthz.AsSystemRestricted(ctx)
// Errored chat with two queued rows: edit the head and save it.
// The chat stays errored.
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID, OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID, Title: "queued edit", Status: database.ChatStatusError,
})
head := insertTestChatQueuedMessage(ctx, t, db, chat.ID, queuedTextContent(t, "original"), modelConfig.ID)
next := insertTestChatQueuedMessage(ctx, t, db, chat.ID, queuedTextContent(t, "next"), modelConfig.ID)
watch, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer watch.Close(websocket.StatusNormalClosure, "done")
require.NoError(t, client.EditChatQueuedMessage(ctx, chat.ID, head.ID, codersdk.EditChatQueuedMessageRequest{Editing: new(true)}))
listed, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.NotNil(t, listed.QueuedMessages[0].EditingSince, "editing_since is visible in the queue listing")
require.NoError(t, client.EditChatQueuedMessage(ctx, chat.ID, head.ID, codersdk.EditChatQueuedMessageRequest{
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "edited"}},
Editing: new(false),
}))
listed, err = client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.Len(t, listed.QueuedMessages, 2, "ending the edit on an errored chat does not promote")
require.Nil(t, listed.QueuedMessages[0].EditingSince)
require.Equal(t, "edited", listed.QueuedMessages[0].Content[0].Text)
fetched, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusError, fetched.Status, "saving the edit keeps the chat errored")
// Pause the chat at the head and resume it: the head is sent.
require.NoError(t, client.EditChatQueuedMessage(ctx, chat.ID, head.ID, codersdk.EditChatQueuedMessageRequest{Editing: new(true)}))
_, err = db.UpdateChatStatus(sysCtx, database.UpdateChatStatusParams{ID: chat.ID, Status: database.ChatStatusPaused})
require.NoError(t, err)
fetched, err = client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusPaused, fetched.Status, "paused is visible on the chat itself")
// Beginning an edit on another row while paused is refused, and
// so is archiving, with a message that names the paused state.
err = client.EditChatQueuedMessage(ctx, chat.ID, next.ID, codersdk.EditChatQueuedMessageRequest{Editing: new(true)})
requireSDKError(t, err, http.StatusConflict)
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: new(true)})
require.Equal(t, pausedArchiveRefusal, requireSDKError(t, err, http.StatusConflict).Message)
require.NoError(t, client.EditChatQueuedMessage(ctx, chat.ID, head.ID, codersdk.EditChatQueuedMessageRequest{Editing: new(false)}))
listed, err = client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.Len(t, listed.QueuedMessages, 1, "the head was sent")
require.Equal(t, "edited", listed.Messages[len(listed.Messages)-1].Content[0].Text)
refreshed, err := db.GetChatByID(sysCtx, chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusRunning, refreshed.Status)
require.Equal(t, codersdk.ChatStatusRunning, waitForChatWatchStatusChangeEvent(ctx, t, watch, chat.ID).Chat.Status,
"resuming publishes the running status to watchers")
// Beginning an edit on a row that was already sent is a 404.
err = client.EditChatQueuedMessage(ctx, chat.ID, head.ID, codersdk.EditChatQueuedMessageRequest{Editing: new(true)})
require.Equal(t, "Queued message not found.", requireSDKError(t, err, http.StatusNotFound).Message)
// Pause at the remaining row and delete it: the chat returns to waiting.
require.NoError(t, client.EditChatQueuedMessage(ctx, chat.ID, next.ID, codersdk.EditChatQueuedMessageRequest{Editing: new(true)}))
_, err = db.UpdateChatStatus(sysCtx, database.UpdateChatStatusParams{ID: chat.ID, Status: database.ChatStatusPaused})
require.NoError(t, err)
res, err := client.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/chats/%s/queue/%d", chat.ID, next.ID), nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNoContent, res.StatusCode)
refreshed, err = db.GetChatByID(sysCtx, chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusWaiting, refreshed.Status)
require.Equal(t, codersdk.ChatStatusWaiting, waitForChatWatchStatusChangeEvent(ctx, t, watch, chat.ID).Chat.Status,
"deleting the paused head publishes the waiting status to watchers")
// Editing a removed row is a 404 when the queue is empty too.
err = client.EditChatQueuedMessage(ctx, chat.ID, next.ID, codersdk.EditChatQueuedMessageRequest{Editing: new(false)})
require.Equal(t, "Queued message not found.", requireSDKError(t, err, http.StatusNotFound).Message)
})
// Overrides sent with content are stored; a later content-only edit
// keeps them.
t.Run("OverridesApplyWithContent", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
otherModel := createAdditionalChatModel(t, client, coderdtest.TestChatProviderOpenAICompat, "gpt-4o-mini-queued-edit-"+uuid.NewString())
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID, OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID, Title: "queued edit overrides", Status: database.ChatStatusError,
})
queued := insertTestChatQueuedMessage(ctx, t, db, chat.ID, queuedTextContent(t, "original"), modelConfig.ID)
require.NoError(t, client.EditChatQueuedMessage(ctx, chat.ID, queued.ID, codersdk.EditChatQueuedMessageRequest{
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "with overrides"}},
ModelConfigID: &otherModel.ID,
ReasoningEffort: ptr.Ref("high"),
}))
listed, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.Len(t, listed.QueuedMessages, 1)
require.Equal(t, &otherModel.ID, listed.QueuedMessages[0].ModelConfigID)
require.Equal(t, ptr.Ref("high"), listed.QueuedMessages[0].ReasoningEffort)
require.NoError(t, client.EditChatQueuedMessage(ctx, chat.ID, queued.ID, codersdk.EditChatQueuedMessageRequest{
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "content only"}},
}))
listed, err = client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.Equal(t, "content only", listed.QueuedMessages[0].Content[0].Text)
require.Equal(t, &otherModel.ID, listed.QueuedMessages[0].ModelConfigID, "a content-only edit keeps the model")
require.Equal(t, ptr.Ref("high"), listed.QueuedMessages[0].ReasoningEffort, "a content-only edit keeps the effort")
})
// The archive refusal names the paused state when a child, not the
// root, is the member that refuses.
t.Run("ArchiveRefusedByPausedChild", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
sysCtx := dbauthz.AsSystemRestricted(ctx)
root := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID, OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID, Title: "idle root",
})
child := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID, OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID, Title: "paused child", Status: database.ChatStatusPaused,
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
})
queued := insertTestChatQueuedMessage(ctx, t, db, child.ID, queuedTextContent(t, "under edit"), modelConfig.ID)
_, err := db.UpdateChatQueuedMessageEditing(sysCtx, database.UpdateChatQueuedMessageEditingParams{
ID: queued.ID, ChatID: child.ID, Editing: true,
})
require.NoError(t, err)
err = client.UpdateChat(ctx, root.ID, codersdk.UpdateChatRequest{Archived: new(true)})
require.Equal(t, pausedArchiveRefusal, requireSDKError(t, err, http.StatusConflict).Message)
refreshed, err := db.GetChatByID(sysCtx, root.ID)
require.NoError(t, err)
require.False(t, refreshed.Archived)
})
t.Run("Guards", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID, OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID, Title: "queued edit guards", Status: database.ChatStatusError,
})
queued := insertTestChatQueuedMessage(ctx, t, db, chat.ID, queuedTextContent(t, "original"), modelConfig.ID)
path := fmt.Sprintf("/api/v2/chats/%s/queue/%d", chat.ID, queued.ID)
err := client.EditChatQueuedMessage(ctx, chat.ID, queued.ID, codersdk.EditChatQueuedMessageRequest{})
require.Equal(t, "Nothing to edit.", requireSDKError(t, err, http.StatusBadRequest).Message)
// Overrides without content return 400, and the request that
// also sets editing: true does not begin an edit.
err = client.EditChatQueuedMessage(ctx, chat.ID, queued.ID, codersdk.EditChatQueuedMessageRequest{
Editing: new(false),
ModelConfigID: &modelConfig.ID,
})
require.Equal(t, "model_config_id and reasoning_effort require content.", requireSDKError(t, err, http.StatusBadRequest).Message)
err = client.EditChatQueuedMessage(ctx, chat.ID, queued.ID, codersdk.EditChatQueuedMessageRequest{
Editing: new(true),
ReasoningEffort: ptr.Ref("high"),
})
require.Equal(t, "model_config_id and reasoning_effort require content.", requireSDKError(t, err, http.StatusBadRequest).Message)
afterRefusal, err := db.GetChatQueuedMessageByID(dbauthz.AsSystemRestricted(ctx), database.GetChatQueuedMessageByIDParams{ID: queued.ID, ChatID: chat.ID})
require.NoError(t, err)
require.False(t, afterRefusal.EditingSince.Valid, "the refused request does not begin an edit")
err = client.EditChatQueuedMessage(ctx, chat.ID, queued.ID, codersdk.EditChatQueuedMessageRequest{
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "edited"}},
ReasoningEffort: ptr.Ref("bogus"),
})
require.Equal(t, "Invalid reasoning_effort value.", requireSDKError(t, err, http.StatusBadRequest).Message)
// The SDK omits empty slices, so send the raw body.
res, err := client.Request(ctx, http.MethodPatch, path, map[string]any{"content": []any{}})
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, "Content is required.", requireSDKError(t, codersdk.ReadBodyAsError(res), http.StatusBadRequest).Message)
res, err = client.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/chats/%s/queue/not-an-int", chat.ID), codersdk.EditChatQueuedMessageRequest{Editing: new(true)})
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, "Invalid queued message ID.", requireSDKError(t, codersdk.ReadBodyAsError(res), http.StatusBadRequest).Message)
// PATCH and DELETE are owner-only.
adminRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, user.OrganizationID, rbac.ScopedRoleOrgAdmin(user.OrganizationID))
err = codersdk.NewExperimentalClient(adminRaw).EditChatQueuedMessage(ctx, chat.ID, queued.ID, codersdk.EditChatQueuedMessageRequest{Editing: new(true)})
require.Equal(t, "Only the chat owner may edit queued messages.", requireSDKError(t, err, http.StatusForbidden).Message)
res, err = adminRaw.Request(ctx, http.MethodDelete, path, nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, "Only the chat owner may delete queued messages.", requireSDKError(t, codersdk.ReadBodyAsError(res), http.StatusForbidden).Message)
_, err = db.ArchiveChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
err = client.EditChatQueuedMessage(ctx, chat.ID, queued.ID, codersdk.EditChatQueuedMessageRequest{Editing: new(true)})
require.Equal(t, "Cannot edit queued messages in an archived chat.", requireSDKError(t, err, http.StatusBadRequest).Message)
})
}