mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: allow renaming of agent chat title (#24489)
Co-authored-by: Coder Agents <noreply@coder.com>
This commit is contained in:
co-authored by
Coder Agents
parent
18a30a7a10
commit
410f9a5e19
@@ -1257,6 +1257,7 @@ func New(options *Options) *API {
|
||||
r.Post("/interrupt", api.interruptChat)
|
||||
r.Post("/tool-results", api.postChatToolResults)
|
||||
r.Post("/title/regenerate", api.regenerateChatTitle)
|
||||
r.Post("/title/propose", api.proposeChatTitle)
|
||||
r.Get("/diff", api.getChatDiffContents)
|
||||
r.Route("/queue/{queuedMessage}", func(r chi.Router) {
|
||||
r.Delete("/", api.deleteChatQueuedMessage)
|
||||
|
||||
@@ -6206,6 +6206,17 @@ func (q *querier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg dat
|
||||
return q.db.UpdateChatStatusPreserveUpdatedAt(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateChatTitleByID(ctx context.Context, arg database.UpdateChatTitleByIDParams) (database.Chat, error) {
|
||||
chat, err := q.db.GetChatByID(ctx, arg.ID)
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
return q.db.UpdateChatTitleByID(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateChatWorkspaceBinding(ctx context.Context, arg database.UpdateChatWorkspaceBindingParams) (database.Chat, error) {
|
||||
chat, err := q.db.GetChatByID(ctx, arg.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -983,6 +983,16 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().UpdateChatByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
|
||||
}))
|
||||
s.Run("UpdateChatTitleByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
chat := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
arg := database.UpdateChatTitleByIDParams{
|
||||
ID: chat.ID,
|
||||
Title: "Updated title",
|
||||
}
|
||||
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdateChatTitleByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
|
||||
}))
|
||||
s.Run("UpdateChatLabelsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
chat := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
arg := database.UpdateChatLabelsByIDParams{
|
||||
|
||||
@@ -4472,6 +4472,14 @@ func (m queryMetricsStore) UpdateChatStatusPreserveUpdatedAt(ctx context.Context
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateChatTitleByID(ctx context.Context, arg database.UpdateChatTitleByIDParams) (database.Chat, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateChatTitleByID(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateChatTitleByID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatTitleByID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateChatWorkspaceBinding(ctx context.Context, arg database.UpdateChatWorkspaceBindingParams) (database.Chat, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateChatWorkspaceBinding(ctx, arg)
|
||||
|
||||
@@ -8460,6 +8460,21 @@ func (mr *MockStoreMockRecorder) UpdateChatStatusPreserveUpdatedAt(ctx, arg any)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatStatusPreserveUpdatedAt", reflect.TypeOf((*MockStore)(nil).UpdateChatStatusPreserveUpdatedAt), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateChatTitleByID mocks base method.
|
||||
func (m *MockStore) UpdateChatTitleByID(ctx context.Context, arg database.UpdateChatTitleByIDParams) (database.Chat, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateChatTitleByID", ctx, arg)
|
||||
ret0, _ := ret[0].(database.Chat)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateChatTitleByID indicates an expected call of UpdateChatTitleByID.
|
||||
func (mr *MockStoreMockRecorder) UpdateChatTitleByID(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatTitleByID", reflect.TypeOf((*MockStore)(nil).UpdateChatTitleByID), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateChatWorkspaceBinding mocks base method.
|
||||
func (m *MockStore) UpdateChatWorkspaceBinding(ctx context.Context, arg database.UpdateChatWorkspaceBindingParams) (database.Chat, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -1057,6 +1057,7 @@ type sqlcQuerier interface {
|
||||
UpdateChatProvider(ctx context.Context, arg UpdateChatProviderParams) (ChatProvider, error)
|
||||
UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error)
|
||||
UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg UpdateChatStatusPreserveUpdatedAtParams) (Chat, error)
|
||||
UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error)
|
||||
UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error)
|
||||
UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error)
|
||||
UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error)
|
||||
|
||||
@@ -8565,6 +8565,60 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateChatTitleByID = `-- name: UpdateChatTitleByID :one
|
||||
UPDATE
|
||||
chats
|
||||
SET
|
||||
-- NOTE: updated_at is intentionally NOT touched here to avoid
|
||||
-- changing list ordering when a user renames an older chat
|
||||
-- out-of-band.
|
||||
title = $1::text
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type
|
||||
`
|
||||
|
||||
type UpdateChatTitleByIDParams struct {
|
||||
Title string `db:"title" json:"title"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateChatTitleByID, arg.Title, arg.ID)
|
||||
var i Chat
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Status,
|
||||
&i.WorkerID,
|
||||
&i.StartedAt,
|
||||
&i.HeartbeatAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentChatID,
|
||||
&i.RootChatID,
|
||||
&i.LastModelConfigID,
|
||||
&i.Archived,
|
||||
&i.LastError,
|
||||
&i.Mode,
|
||||
pq.Array(&i.MCPServerIDs),
|
||||
&i.Labels,
|
||||
&i.BuildID,
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
&i.ClientType,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateChatWorkspaceBinding = `-- name: UpdateChatWorkspaceBinding :one
|
||||
UPDATE chats SET
|
||||
workspace_id = $1::uuid,
|
||||
|
||||
@@ -553,6 +553,19 @@ WHERE
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: UpdateChatTitleByID :one
|
||||
UPDATE
|
||||
chats
|
||||
SET
|
||||
-- NOTE: updated_at is intentionally NOT touched here to avoid
|
||||
-- changing list ordering when a user renames an older chat
|
||||
-- out-of-band.
|
||||
title = @title::text
|
||||
WHERE
|
||||
id = @id::uuid
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: UpdateChatPlanModeByID :one
|
||||
UPDATE
|
||||
chats
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
@@ -111,6 +112,30 @@ 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,
|
||||
@@ -1929,6 +1954,86 @@ func (api *API) watchChatDesktop(rw http.ResponseWriter, r *http.Request) {
|
||||
logger.Debug(ctx, "desktop Bicopy finished")
|
||||
}
|
||||
|
||||
func (api *API) applyChatTitleUpdate(
|
||||
ctx context.Context,
|
||||
rw http.ResponseWriter,
|
||||
chat database.Chat,
|
||||
rawTitle string,
|
||||
) (database.Chat, bool) {
|
||||
trimmedTitle := strings.TrimSpace(rawTitle)
|
||||
if trimmedTitle == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Title cannot be empty.",
|
||||
})
|
||||
return chat, true
|
||||
}
|
||||
const maxChatTitleRunes = 200
|
||||
if utf8.RuneCountInString(trimmedTitle) > maxChatTitleRunes {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Title must be at most %d characters.", maxChatTitleRunes),
|
||||
})
|
||||
return chat, true
|
||||
}
|
||||
if trimmedTitle == chat.Title {
|
||||
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)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, chatd.ErrManualTitleRegenerationInProgress) {
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "Title regeneration already in progress for this chat.",
|
||||
})
|
||||
return chat, true
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return chat, true
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to update chat title.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return chat, true
|
||||
}
|
||||
if wrote {
|
||||
if api.chatDaemon != nil {
|
||||
api.chatDaemon.PublishTitleChange(updatedChat)
|
||||
} else {
|
||||
publishChatTitleChange(api.Logger, api.Pubsub, updatedChat)
|
||||
}
|
||||
}
|
||||
return updatedChat, false
|
||||
}
|
||||
|
||||
// patchChat updates a chat resource. Supports updating labels,
|
||||
// workspace binding, archiving, pinning, and pinned-chat ordering.
|
||||
func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -1952,6 +2057,13 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
|
||||
planModeUpdate = &resolvedPlanMode
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
updatedChat, handled := api.applyChatTitleUpdate(ctx, rw, chat, *req.Title)
|
||||
if handled {
|
||||
return
|
||||
}
|
||||
chat = updatedChat
|
||||
}
|
||||
if req.Labels != nil {
|
||||
if errs := httpapi.ValidateChatLabels(*req.Labels); len(errs) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
@@ -2756,6 +2868,48 @@ func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updatedChat, nil, nil))
|
||||
}
|
||||
|
||||
//nolint:revive // HTTP handler writes to ResponseWriter.
|
||||
func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
chat := httpmw.ChatParam(r)
|
||||
|
||||
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
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) {
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "Title regeneration already in progress for this chat.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if maybeWriteLimitErr(ctx, rw, err) {
|
||||
return
|
||||
}
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to generate chat title.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ProposeChatTitleResponse{Title: title})
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
//nolint:revive // HTTP handler writes to ResponseWriter.
|
||||
|
||||
@@ -4149,6 +4149,271 @@ func TestPatchChat(t *testing.T) {
|
||||
require.Nil(t, updated.AgentID)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Title", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Rename", 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 := createChat(ctx, t, client, firstUser.OrganizationID, "original title")
|
||||
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
Title: ptr.Ref("renamed title"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
require.Equal(t, "renamed title", updated.Title)
|
||||
})
|
||||
|
||||
t.Run("TrimsWhitespace", 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 := createChat(ctx, t, client, firstUser.OrganizationID, "before trim")
|
||||
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
Title: ptr.Ref(" padded title "),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
require.Equal(t, "padded title", updated.Title)
|
||||
})
|
||||
|
||||
t.Run("RejectsEmpty", 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 := createChat(ctx, t, client, firstUser.OrganizationID, "keep original")
|
||||
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
Title: ptr.Ref(" "),
|
||||
})
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
require.Equal(t, chat.Title, updated.Title)
|
||||
})
|
||||
|
||||
t.Run("RejectsTooLong", 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 := createChat(ctx, t, client, firstUser.OrganizationID, "keep original length")
|
||||
|
||||
tooLong := strings.Repeat("a", 201)
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
Title: ptr.Ref(tooLong),
|
||||
})
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
require.Equal(t, chat.Title, updated.Title)
|
||||
})
|
||||
|
||||
t.Run("LengthBoundaries", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
title string
|
||||
expectOK bool
|
||||
storedAs string
|
||||
}{
|
||||
{
|
||||
name: "ExactlyMaxASCII",
|
||||
title: strings.Repeat("a", 200),
|
||||
expectOK: true,
|
||||
storedAs: strings.Repeat("a", 200),
|
||||
},
|
||||
{
|
||||
name: "OneOverMaxASCII",
|
||||
title: strings.Repeat("a", 201),
|
||||
expectOK: false,
|
||||
},
|
||||
{
|
||||
name: "ExactlyMaxMultiByte",
|
||||
title: strings.Repeat("é", 200),
|
||||
expectOK: true,
|
||||
storedAs: strings.Repeat("é", 200),
|
||||
},
|
||||
{
|
||||
name: "OneOverMaxMultiByte",
|
||||
title: strings.Repeat("é", 201),
|
||||
expectOK: false,
|
||||
},
|
||||
{
|
||||
name: "TrimsDownToMax",
|
||||
title: " " + strings.Repeat("a", 200) + " ",
|
||||
expectOK: true,
|
||||
storedAs: strings.Repeat("a", 200),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, 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 := createChat(ctx, t, client, firstUser.OrganizationID, "boundary baseline")
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
Title: ptr.Ref(tc.title),
|
||||
})
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
if tc.expectOK {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.storedAs, updated.Title)
|
||||
} else {
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, chat.Title, updated.Title)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PreservesUpdatedAt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
||||
clientRaw := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentValues: chatDeploymentValues(t),
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
})
|
||||
client := codersdk.NewExperimentalClient(clientRaw)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModelConfig(t, client)
|
||||
|
||||
chat := createChat(ctx, t, client, firstUser.OrganizationID, "rename me")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
c, getErr := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
if getErr != nil {
|
||||
return false
|
||||
}
|
||||
return c.Status != database.ChatStatusPending &&
|
||||
c.Status != database.ChatStatusRunning
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
past := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second)
|
||||
_, err := sqlDB.ExecContext(ctx,
|
||||
"UPDATE chats SET updated_at = $1 WHERE id = $2",
|
||||
past, chat.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
Title: ptr.Ref("renamed in place"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
require.Equal(t, "renamed in place", updated.Title)
|
||||
require.WithinDuration(t, past, updated.UpdatedAt, time.Second,
|
||||
"rename bumped updated_at; it should be preserved to keep list ordering stable")
|
||||
})
|
||||
|
||||
t.Run("NoOpWhenTitleUnchanged", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
||||
clientRaw := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentValues: chatDeploymentValues(t),
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
})
|
||||
client := codersdk.NewExperimentalClient(clientRaw)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModelConfig(t, client)
|
||||
|
||||
chat := createChat(ctx, t, client, firstUser.OrganizationID, "steady title")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
c, getErr := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
if getErr != nil {
|
||||
return false
|
||||
}
|
||||
return c.Status != database.ChatStatusPending &&
|
||||
c.Status != database.ChatStatusRunning
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
past := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second)
|
||||
_, err := sqlDB.ExecContext(ctx,
|
||||
"UPDATE chats SET title = $1, updated_at = $2 WHERE id = $3",
|
||||
"steady title", past, chat.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
Title: ptr.Ref("steady title"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
require.Equal(t, "steady title", updated.Title)
|
||||
require.WithinDuration(t, past, updated.UpdatedAt, time.Second,
|
||||
"no-op rename bumped updated_at; it should have been short-circuited before the write")
|
||||
})
|
||||
|
||||
t.Run("PublishesWatchEvent", 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 := createChat(ctx, t, client, firstUser.OrganizationID, "announce me")
|
||||
|
||||
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close(websocket.StatusNormalClosure, "done")
|
||||
|
||||
go func() {
|
||||
_ = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
Title: ptr.Ref("announced name"),
|
||||
})
|
||||
}()
|
||||
|
||||
var received codersdk.ChatWatchEvent
|
||||
for {
|
||||
if err := wsjson.Read(ctx, conn, &received); err != nil {
|
||||
break
|
||||
}
|
||||
if received.Kind == codersdk.ChatWatchEventKindTitleChange &&
|
||||
received.Chat.ID == chat.ID {
|
||||
require.Equal(t, "announced name", received.Chat.Title)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("did not observe title_change event for chat %s", chat.ID)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestArchiveChat(t *testing.T) {
|
||||
@@ -6592,6 +6857,92 @@ func TestRegenerateChatTitle(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestProposeChatTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ChatNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
_ = coderdtest.CreateFirstUser(t, client.Client)
|
||||
|
||||
_, err := client.ProposeChatTitle(ctx, uuid.New())
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("UpdateDenied", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
clientRaw, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
Authorizer: &coderdtest.FakeAuthorizer{
|
||||
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
|
||||
if action == policy.ActionUpdate && object.Type == rbac.ResourceChat.Type {
|
||||
return xerrors.New("denied")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
DeploymentValues: chatDeploymentValues(t),
|
||||
})
|
||||
client := codersdk.NewExperimentalClient(clientRaw)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
OrganizationID: user.OrganizationID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
ClientType: database.ChatClientTypeUi,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "chat with update denied",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ProposeChatTitle(ctx, chat.ID)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("DoesNotPersistTitleOrBumpUpdatedAt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(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: "test chat"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
c, getErr := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
if getErr != nil {
|
||||
return false
|
||||
}
|
||||
return c.Status != database.ChatStatusPending && c.Status != database.ChatStatusRunning
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
before, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ProposeChatTitle(ctx, chat.ID)
|
||||
requireSDKError(t, err, http.StatusInternalServerError)
|
||||
|
||||
after, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, before.Title, after.Title,
|
||||
"propose must not persist the suggested title")
|
||||
require.True(t, after.UpdatedAt.Equal(before.UpdatedAt),
|
||||
"propose must not bump updated_at")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetChatDiffStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+162
-48
@@ -2196,44 +2196,112 @@ func (p *Server) RegenerateChatTitle(
|
||||
keys,
|
||||
)
|
||||
if err != nil {
|
||||
var generationErr *manualTitleGenerationError
|
||||
if errors.As(err, &generationErr) {
|
||||
// Reuse chatd's scoped auth context for failure accounting while
|
||||
// detaching from request cancellation so usage is still recorded.
|
||||
//nolint:gocritic // Failure accounting still needs chatd-scoped config reads.
|
||||
recordCtx, recordCancel := context.WithTimeout(
|
||||
dbauthz.AsChatd(context.WithoutCancel(ctx)),
|
||||
5*time.Second,
|
||||
)
|
||||
defer recordCancel()
|
||||
if _, recordErr := recordManualTitleUsage(
|
||||
recordCtx,
|
||||
p.db,
|
||||
chat,
|
||||
generationErr.modelConfig,
|
||||
generationErr.usage,
|
||||
"",
|
||||
); recordErr != nil {
|
||||
return database.Chat{}, errors.Join(
|
||||
generationErr,
|
||||
xerrors.Errorf("record manual title usage: %w", recordErr),
|
||||
)
|
||||
}
|
||||
return database.Chat{}, generationErr
|
||||
}
|
||||
return database.Chat{}, err
|
||||
return database.Chat{}, p.recordManualTitleGenerationFailure(ctx, chat, err)
|
||||
}
|
||||
return updatedChat, nil
|
||||
}
|
||||
|
||||
func (p *Server) regenerateChatTitleWithStore(
|
||||
// RenameChatTitle persists a user-supplied chat title.
|
||||
func (p *Server) RenameChatTitle(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
newTitle string,
|
||||
) (updated database.Chat, wrote bool, err error) {
|
||||
//nolint:gocritic // Lock release needs chatd-scoped writes.
|
||||
chatdCtx := dbauthz.AsChatd(ctx)
|
||||
if err := p.acquireManualTitleLock(ctx, chat.ID); err != nil {
|
||||
return database.Chat{}, false, err
|
||||
}
|
||||
defer p.releaseManualTitleLock(chatdCtx, chat.ID)
|
||||
|
||||
currentChat, err := p.db.GetChatByID(ctx, chat.ID)
|
||||
if err != nil {
|
||||
return database.Chat{}, false, xerrors.Errorf("get chat for rename: %w", err)
|
||||
}
|
||||
if newTitle == currentChat.Title {
|
||||
return currentChat, false, nil
|
||||
}
|
||||
|
||||
updatedChat, err := p.db.UpdateChatTitleByID(ctx, database.UpdateChatTitleByIDParams{
|
||||
ID: chat.ID,
|
||||
Title: newTitle,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Chat{}, false, xerrors.Errorf("update chat title: %w", err)
|
||||
}
|
||||
return updatedChat, true, nil
|
||||
}
|
||||
|
||||
// PublishTitleChange broadcasts a title_change event for the given chat.
|
||||
func (p *Server) PublishTitleChange(chat database.Chat) {
|
||||
p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindTitleChange, nil)
|
||||
}
|
||||
|
||||
// ProposeChatTitle generates a title suggestion from the chat's visible messages without persisting it.
|
||||
func (p *Server) ProposeChatTitle(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
) (string, error) {
|
||||
//nolint:gocritic // Non-admin users need chatd-scoped config reads here.
|
||||
chatdCtx := dbauthz.AsChatd(ctx)
|
||||
keys, err := p.resolveUserProviderAPIKeys(chatdCtx, chat.OwnerID)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("resolve chat providers: %w", err)
|
||||
}
|
||||
if err := p.acquireManualTitleLock(ctx, chat.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer p.releaseManualTitleLock(chatdCtx, chat.ID)
|
||||
|
||||
title, err := p.proposeChatTitleWithStore(chatdCtx, p.db, chat, keys)
|
||||
if err != nil {
|
||||
return "", p.recordManualTitleGenerationFailure(ctx, chat, err)
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
func (p *Server) recordManualTitleGenerationFailure(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
err error,
|
||||
) error {
|
||||
var generationErr *manualTitleGenerationError
|
||||
if !errors.As(err, &generationErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:gocritic // Failure accounting still needs chatd-scoped config reads.
|
||||
recordCtx, recordCancel := context.WithTimeout(
|
||||
dbauthz.AsChatd(context.WithoutCancel(ctx)),
|
||||
5*time.Second,
|
||||
)
|
||||
defer recordCancel()
|
||||
if _, recordErr := recordManualTitleUsage(
|
||||
recordCtx,
|
||||
p.db,
|
||||
chat,
|
||||
generationErr.modelConfig,
|
||||
generationErr.usage,
|
||||
"",
|
||||
); recordErr != nil {
|
||||
return errors.Join(
|
||||
generationErr,
|
||||
xerrors.Errorf("record manual title usage: %w", recordErr),
|
||||
)
|
||||
}
|
||||
return generationErr
|
||||
}
|
||||
|
||||
//nolint:revive // flag-parameter: enableDebug toggles optional debug capture on a shared code path; splitting would duplicate message fetch and model resolution.
|
||||
func (p *Server) fetchAndGenerateManualTitle(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chat database.Chat,
|
||||
keys chatprovider.ProviderAPIKeys,
|
||||
) (database.Chat, error) {
|
||||
enableDebug bool,
|
||||
) (title string, modelConfig database.ChatModelConfig, usage fantasy.Usage, hasMessages bool, err error) {
|
||||
if limitErr := p.checkUsageLimit(ctx, store, chat.OwnerID, uuid.NullUUID{UUID: chat.OrganizationID, Valid: true}); limitErr != nil {
|
||||
return database.Chat{}, limitErr
|
||||
return "", database.ChatModelConfig{}, fantasy.Usage{}, false, limitErr
|
||||
}
|
||||
|
||||
headMessages, err := store.GetChatMessagesByChatIDAscPaginated(
|
||||
@@ -2245,7 +2313,7 @@ func (p *Server) regenerateChatTitleWithStore(
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("get head chat messages: %w", err)
|
||||
return "", database.ChatModelConfig{}, fantasy.Usage{}, false, xerrors.Errorf("get head chat messages: %w", err)
|
||||
}
|
||||
tailMessages, err := store.GetChatMessagesByChatIDDescPaginated(
|
||||
ctx,
|
||||
@@ -2256,49 +2324,95 @@ func (p *Server) regenerateChatTitleWithStore(
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("get tail chat messages: %w", err)
|
||||
return "", database.ChatModelConfig{}, fantasy.Usage{}, false, xerrors.Errorf("get tail chat messages: %w", err)
|
||||
}
|
||||
messages := mergeManualTitleMessages(headMessages, tailMessages)
|
||||
if len(messages) == 0 {
|
||||
return chat, nil
|
||||
return "", database.ChatModelConfig{}, fantasy.Usage{}, false, nil
|
||||
}
|
||||
|
||||
model, modelConfig, err := p.resolveManualTitleModel(ctx, store, chat, keys)
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
return "", database.ChatModelConfig{}, fantasy.Usage{}, true, err
|
||||
}
|
||||
|
||||
debugSvc := p.debugService()
|
||||
debugEnabled := debugSvc != nil && debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID)
|
||||
titleCtx := ctx
|
||||
titleModel := model
|
||||
finishDebugRun := func(error) {}
|
||||
if debugEnabled {
|
||||
titleCtx, titleModel, finishDebugRun = p.prepareManualTitleDebugRun(
|
||||
ctx,
|
||||
debugSvc,
|
||||
chat,
|
||||
modelConfig,
|
||||
keys,
|
||||
messages,
|
||||
model,
|
||||
)
|
||||
if enableDebug {
|
||||
if debugSvc := p.debugService(); debugSvc != nil && debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID) {
|
||||
titleCtx, titleModel, finishDebugRun = p.prepareManualTitleDebugRun(
|
||||
ctx,
|
||||
debugSvc,
|
||||
chat,
|
||||
modelConfig,
|
||||
keys,
|
||||
messages,
|
||||
model,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
title, usage, err := generateManualTitle(titleCtx, messages, titleModel)
|
||||
title, usage, err = generateManualTitle(titleCtx, messages, titleModel)
|
||||
finishDebugRun(err)
|
||||
if err != nil {
|
||||
wrappedErr := xerrors.Errorf("generate manual title: %w", err)
|
||||
if usage == (fantasy.Usage{}) {
|
||||
return database.Chat{}, wrappedErr
|
||||
return "", modelConfig, fantasy.Usage{}, true, wrappedErr
|
||||
}
|
||||
return database.Chat{}, &manualTitleGenerationError{
|
||||
return "", modelConfig, usage, true, &manualTitleGenerationError{
|
||||
cause: wrappedErr,
|
||||
modelConfig: modelConfig,
|
||||
usage: usage,
|
||||
}
|
||||
}
|
||||
|
||||
return title, modelConfig, usage, true, nil
|
||||
}
|
||||
|
||||
func (p *Server) proposeChatTitleWithStore(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chat database.Chat,
|
||||
keys chatprovider.ProviderAPIKeys,
|
||||
) (string, error) {
|
||||
title, modelConfig, usage, hasMessages, err := p.fetchAndGenerateManualTitle(ctx, store, chat, keys, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasMessages {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
recordCtx, recordCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer recordCancel()
|
||||
if _, recordErr := recordManualTitleUsage(
|
||||
recordCtx,
|
||||
store,
|
||||
chat,
|
||||
modelConfig,
|
||||
usage,
|
||||
"",
|
||||
); recordErr != nil {
|
||||
return "", xerrors.Errorf("record manual title usage: %w", recordErr)
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
func (p *Server) regenerateChatTitleWithStore(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chat database.Chat,
|
||||
keys chatprovider.ProviderAPIKeys,
|
||||
) (database.Chat, error) {
|
||||
title, modelConfig, usage, hasMessages, err := p.fetchAndGenerateManualTitle(ctx, store, chat, keys, true)
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
if !hasMessages {
|
||||
return chat, nil
|
||||
}
|
||||
|
||||
recordCtx, recordCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer recordCancel()
|
||||
|
||||
|
||||
@@ -287,6 +287,99 @@ func TestStopAfterBehaviorTools(t *testing.T) {
|
||||
// TestArchiveChatWaitsForEveryInterruptedChat were removed along with
|
||||
// the process-local activeChats mechanism. Archive cleanup is now
|
||||
// best-effort; stale finalization handles any orphaned rows.
|
||||
|
||||
func TestRenameChatTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupRealWorkerLock := func(
|
||||
db *dbmock.MockStore,
|
||||
chatID uuid.UUID,
|
||||
lockedChat database.Chat,
|
||||
) {
|
||||
lockTx := dbmock.NewMockStore(gomock.NewController(t))
|
||||
unlockTx := dbmock.NewMockStore(gomock.NewController(t))
|
||||
gomock.InOrder(
|
||||
db.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("chat_title_regenerate_lock")).DoAndReturn(
|
||||
func(fn func(database.Store) error, _ *database.TxOptions) error {
|
||||
return fn(lockTx)
|
||||
},
|
||||
),
|
||||
db.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("chat_title_regenerate_unlock")).DoAndReturn(
|
||||
func(fn func(database.Store) error, _ *database.TxOptions) error {
|
||||
return fn(unlockTx)
|
||||
},
|
||||
),
|
||||
)
|
||||
lockTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(lockedChat, nil)
|
||||
unlockTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(lockedChat, nil)
|
||||
}
|
||||
|
||||
t.Run("WritesAndReturnsWroteTrue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
|
||||
chatID := uuid.New()
|
||||
workerID := uuid.New()
|
||||
stored := database.Chat{
|
||||
ID: chatID,
|
||||
Status: database.ChatStatusRunning,
|
||||
WorkerID: uuid.NullUUID{UUID: workerID, Valid: true},
|
||||
Title: "original",
|
||||
}
|
||||
updated := stored
|
||||
updated.Title = "renamed"
|
||||
|
||||
server := &Server{db: db, logger: logger}
|
||||
|
||||
setupRealWorkerLock(db, chatID, stored)
|
||||
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(stored, nil)
|
||||
db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{
|
||||
ID: chatID,
|
||||
Title: "renamed",
|
||||
}).Return(updated, nil)
|
||||
|
||||
got, wrote, err := server.RenameChatTitle(ctx, stored, "renamed")
|
||||
require.NoError(t, err)
|
||||
require.True(t, wrote, "fresh rename must report wrote=true")
|
||||
require.Equal(t, updated, got)
|
||||
})
|
||||
|
||||
t.Run("SkipsWriteWhenAlreadyAtNewTitle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
|
||||
chatID := uuid.New()
|
||||
workerID := uuid.New()
|
||||
stale := database.Chat{
|
||||
ID: chatID,
|
||||
Status: database.ChatStatusRunning,
|
||||
WorkerID: uuid.NullUUID{UUID: workerID, Valid: true},
|
||||
Title: "pre-race",
|
||||
}
|
||||
landed := stale
|
||||
landed.Title = "landed-concurrently"
|
||||
|
||||
server := &Server{db: db, logger: logger}
|
||||
|
||||
setupRealWorkerLock(db, chatID, landed)
|
||||
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(landed, nil)
|
||||
|
||||
got, wrote, err := server.RenameChatTitle(ctx, stale, "landed-concurrently")
|
||||
require.NoError(t, err)
|
||||
require.False(t, wrote,
|
||||
"must report wrote=false when the stored row already matches newTitle so the handler suppresses a redundant title_change event")
|
||||
require.Equal(t, landed, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user