mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix(coderd): stop manual title generation from writing to chat_messages (#27087)
Coder Agents chats could get stuck showing "Thinking" forever when a title regenerate/propose request ran while a generation was in flight. Manual title generation recorded token cost by inserting a hidden assistant message into `chat_messages` and immediately soft-deleting it. Triggers on that table sync `chats.history_version` to `snapshot_version`, so this out-of-band write broke the `history_version` fence of an in-flight generation task, killing it without a replacement and leaving the chat stuck in `running`. Remove the accounting path entirely; AI Gateway already records title-call usage in `aibridge_interceptions`/`aibridge_token_usages`. The manual title endpoints no longer write to `chat_messages` at all, and new regression tests assert `history_version` stays untouched. Note this intentionally drops title-generation cost from chatd's chat-level cost surfaces; it still counts against the user's AI budget via AI Gateway. Closes CODAGT-595
This commit is contained in:
@@ -9085,6 +9085,43 @@ func TestRegenerateChatTitle(t *testing.T) {
|
||||
require.Equal(t, "Test Chat", updated.Title)
|
||||
})
|
||||
|
||||
t.Run("DoesNotBumpHistoryVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createTitleGenerationModelConfig(t, client)
|
||||
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "history fence chat",
|
||||
})
|
||||
seedManualTitleSourceMessage(t, db, chat, modelConfig.ID)
|
||||
|
||||
// Leave history_version lagging snapshot_version, as when a
|
||||
// generation task is in flight. A chat_messages write here would
|
||||
// sync it and break that task's commit fence.
|
||||
_, err := db.LockChatAndBumpSnapshotVersion(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
before, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, before.SnapshotVersion, before.HistoryVersion,
|
||||
"setup must leave history_version lagging snapshot_version")
|
||||
|
||||
updated, err := client.RegenerateChatTitle(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Test Chat", updated.Title)
|
||||
|
||||
after, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, before.HistoryVersion, after.HistoryVersion,
|
||||
"manual title regeneration must not touch chat_messages")
|
||||
})
|
||||
|
||||
t.Run("NoDefaultModelConfig", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -9256,6 +9293,41 @@ func TestProposeChatTitle(t *testing.T) {
|
||||
require.True(t, persisted.UpdatedAt.Equal(before.UpdatedAt))
|
||||
})
|
||||
|
||||
t.Run("DoesNotBumpHistoryVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createTitleGenerationModelConfig(t, client)
|
||||
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "history fence chat",
|
||||
})
|
||||
seedManualTitleSourceMessage(t, db, chat, modelConfig.ID)
|
||||
|
||||
// See the matching TestRegenerateChatTitle subtest.
|
||||
_, err := db.LockChatAndBumpSnapshotVersion(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
before, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, before.SnapshotVersion, before.HistoryVersion,
|
||||
"setup must leave history_version lagging snapshot_version")
|
||||
|
||||
resp, err := client.ProposeChatTitle(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Test Chat", resp.Title)
|
||||
|
||||
after, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, before.HistoryVersion, after.HistoryVersion,
|
||||
"title proposal must not touch chat_messages")
|
||||
})
|
||||
|
||||
t.Run("NoDefaultModelConfig", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ Each row in `chat_messages` has a `revision` column. It stores the `chats.snapsh
|
||||
|
||||
`chats.history_version` stores the latest `snapshot_version` in which chat message history changed. It starts at `0`, remains unchanged for non-history transitions, and is set to the current `snapshot_version` whenever a message is inserted or meaningfully updated. A newly created chat starts with `snapshot_version = 1`; because `Create` inserts initial history in that snapshot, the created chat's `history_version` becomes `1`. No-op message updates do not advance message `revision`, advance `history_version`, or reset `generation_attempt`. Whenever `history_version` changes, `generation_attempt` is reset to `0`; generation attempts are scoped to the current history version.
|
||||
|
||||
Message revision triggers depend on the transition invariant that `snapshot_version` is allocated immediately after the chat row is locked and before any message mutation happens. Runtime code must not assign `chat_messages.revision` directly.
|
||||
Message revision triggers depend on the transition invariant that `snapshot_version` is allocated immediately after the chat row is locked and before any message mutation happens. Runtime code must not assign `chat_messages.revision` directly, and every `chat_messages` insert or update must go through a state machine transition: the triggers advance `history_version` on any write, so an out-of-band write (even of a hidden or soft-deleted row) moves `history_version` without a matching `snapshot_version` bump and breaks the fence of an in-flight generation task.
|
||||
|
||||
A `BEFORE INSERT` trigger assigns the current chat `snapshot_version` to the inserted message row and records the same value as the chat's latest history version:
|
||||
|
||||
|
||||
+35
-240
@@ -40,7 +40,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/webpush"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatcost"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
|
||||
@@ -69,6 +68,7 @@ const (
|
||||
homeInstructionLookupTimeout = 5 * time.Second
|
||||
workspaceDialValidationDelay = 5 * time.Second
|
||||
turnStatusLabelWriteTimeout = 5 * time.Second
|
||||
manualTitlePersistTimeout = 5 * time.Second
|
||||
// defaultDialTimeout matches the timeout used by ~8 other
|
||||
// server-side AgentConn callers.
|
||||
defaultDialTimeout = 30 * time.Second
|
||||
@@ -2129,21 +2129,6 @@ func (p *Server) ReconcileInvalidStateChat(
|
||||
|
||||
const manualTitleMessageWindowLimit = 50
|
||||
|
||||
type manualTitleCandidateResult struct {
|
||||
title string
|
||||
modelConfig database.ChatModelConfig
|
||||
usage fantasy.Usage
|
||||
activeAPIKeyID string
|
||||
hasMessages bool
|
||||
}
|
||||
|
||||
type manualTitleGenerationError struct {
|
||||
cause error
|
||||
modelConfig database.ChatModelConfig
|
||||
usage fantasy.Usage
|
||||
activeAPIKeyID string
|
||||
}
|
||||
|
||||
// generatedChatTitle carries the title produced by the detached
|
||||
// automatic title-generation goroutine. maybeGenerateChatTitle stores
|
||||
// the generated title here so tests can observe it without a database
|
||||
@@ -2177,14 +2162,6 @@ func (t *generatedChatTitle) Load() (string, bool) {
|
||||
return t.title, true
|
||||
}
|
||||
|
||||
func (e *manualTitleGenerationError) Error() string {
|
||||
return e.cause.Error()
|
||||
}
|
||||
|
||||
func (e *manualTitleGenerationError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
// RegenerateChatTitle regenerates a chat title from the chat's visible
|
||||
// messages, persists it when it changes, and broadcasts the update.
|
||||
func (p *Server) RegenerateChatTitle(
|
||||
@@ -2195,15 +2172,11 @@ func (p *Server) RegenerateChatTitle(
|
||||
// keeping chat ownership authorization at the HTTP layer.
|
||||
//nolint:gocritic // Non-admin users need chatd-scoped config reads here.
|
||||
chatdCtx := dbauthz.AsChatd(ctx)
|
||||
updatedChat, err := p.regenerateChatTitleWithStore(
|
||||
return p.regenerateChatTitleWithStore(
|
||||
chatdCtx,
|
||||
p.db,
|
||||
chat,
|
||||
)
|
||||
if err != nil {
|
||||
return database.Chat{}, p.recordManualTitleGenerationFailure(ctx, chat, err)
|
||||
}
|
||||
return updatedChat, nil
|
||||
}
|
||||
|
||||
// RenameChatTitle persists a user-supplied chat title.
|
||||
@@ -2243,57 +2216,20 @@ func (p *Server) ProposeChatTitle(
|
||||
) (string, error) {
|
||||
//nolint:gocritic // Non-admin users need chatd-scoped config reads here.
|
||||
chatdCtx := dbauthz.AsChatd(ctx)
|
||||
title, err := p.proposeChatTitleWithStore(chatdCtx, p.db, chat)
|
||||
if err != nil {
|
||||
return "", p.recordManualTitleGenerationFailure(ctx, chat, err)
|
||||
}
|
||||
return title, nil
|
||||
return p.generateManualTitleCandidate(chatdCtx, p.db, chat)
|
||||
}
|
||||
|
||||
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,
|
||||
generationErr.activeAPIKeyID,
|
||||
"",
|
||||
); recordErr != nil {
|
||||
return errors.Join(
|
||||
generationErr,
|
||||
xerrors.Errorf("record manual title usage: %w", recordErr),
|
||||
)
|
||||
}
|
||||
return generationErr
|
||||
}
|
||||
|
||||
// generateManualTitleCandidate performs only model generation and returns the
|
||||
// candidate plus accounting metadata. Endpoint-specific commit paths are
|
||||
// responsible for recording usage and deciding whether to persist the title.
|
||||
// generateManualTitleCandidate generates a title candidate from the chat's
|
||||
// visible messages. It returns "" when the chat has no messages to summarize.
|
||||
// Endpoint-specific commit paths decide whether to persist the title.
|
||||
// The context may carry the caller's delegated API key for manual title routes.
|
||||
func (p *Server) generateManualTitleCandidate(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chat database.Chat,
|
||||
) (manualTitleCandidateResult, error) {
|
||||
) (string, error) {
|
||||
if limitErr := p.checkUsageLimit(ctx, store, chat.OwnerID, uuid.NullUUID{UUID: chat.OrganizationID, Valid: true}); limitErr != nil {
|
||||
return manualTitleCandidateResult{}, limitErr
|
||||
return "", limitErr
|
||||
}
|
||||
|
||||
headMessages, err := store.GetChatMessagesByChatIDAscPaginated(
|
||||
@@ -2305,7 +2241,7 @@ func (p *Server) generateManualTitleCandidate(
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return manualTitleCandidateResult{}, xerrors.Errorf("get head chat messages: %w", err)
|
||||
return "", xerrors.Errorf("get head chat messages: %w", err)
|
||||
}
|
||||
tailMessages, err := store.GetChatMessagesByChatIDDescPaginated(
|
||||
ctx,
|
||||
@@ -2316,15 +2252,15 @@ func (p *Server) generateManualTitleCandidate(
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return manualTitleCandidateResult{}, xerrors.Errorf("get tail chat messages: %w", err)
|
||||
return "", xerrors.Errorf("get tail chat messages: %w", err)
|
||||
}
|
||||
messages := mergeManualTitleMessages(headMessages, tailMessages)
|
||||
if len(messages) == 0 {
|
||||
return manualTitleCandidateResult{}, nil
|
||||
return "", nil
|
||||
}
|
||||
pasteText, err := titlePasteText(ctx, store, messages)
|
||||
if err != nil {
|
||||
return manualTitleCandidateResult{}, xerrors.Errorf("get pasted-text attachments for manual title: %w", err)
|
||||
return "", xerrors.Errorf("get pasted-text attachments for manual title: %w", err)
|
||||
}
|
||||
modelOpts := modelBuildOptionsFromMessages(messages)
|
||||
// Manual title routes can run over messages that lack API key attribution.
|
||||
@@ -2336,13 +2272,8 @@ func (p *Server) generateManualTitleCandidate(
|
||||
}
|
||||
|
||||
model, modelConfig, err := p.resolveManualTitleModel(ctx, store, chat, modelOpts)
|
||||
result := manualTitleCandidateResult{
|
||||
modelConfig: modelConfig,
|
||||
activeAPIKeyID: modelOpts.ActiveAPIKeyID,
|
||||
hasMessages: true,
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
titleCtx := ctx
|
||||
@@ -2360,7 +2291,7 @@ func (p *Server) generateManualTitleCandidate(
|
||||
)
|
||||
}
|
||||
|
||||
title, usage, err := generateManualTitle(
|
||||
title, err := generateManualTitle(
|
||||
titleCtx,
|
||||
messages,
|
||||
pasteText,
|
||||
@@ -2368,51 +2299,11 @@ func (p *Server) generateManualTitleCandidate(
|
||||
p.titleGenerationProviderOptions(ctx, titleModel, modelConfig),
|
||||
)
|
||||
finishDebugRun(err)
|
||||
result.title = title
|
||||
result.usage = usage
|
||||
if err != nil {
|
||||
wrappedErr := xerrors.Errorf("generate manual title: %w", err)
|
||||
if usage == (fantasy.Usage{}) {
|
||||
return result, wrappedErr
|
||||
}
|
||||
return result, &manualTitleGenerationError{
|
||||
cause: wrappedErr,
|
||||
modelConfig: modelConfig,
|
||||
usage: usage,
|
||||
activeAPIKeyID: modelOpts.ActiveAPIKeyID,
|
||||
}
|
||||
return "", xerrors.Errorf("generate manual title: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Server) proposeChatTitleWithStore(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chat database.Chat,
|
||||
) (string, error) {
|
||||
result, err := p.generateManualTitleCandidate(ctx, store, chat)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !result.hasMessages {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
recordCtx, recordCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer recordCancel()
|
||||
if _, _, recordErr := recordManualTitleUsage(
|
||||
recordCtx,
|
||||
store,
|
||||
chat,
|
||||
result.modelConfig,
|
||||
result.usage,
|
||||
result.activeAPIKeyID,
|
||||
"",
|
||||
); recordErr != nil {
|
||||
return "", xerrors.Errorf("record manual title usage: %w", recordErr)
|
||||
}
|
||||
return result.title, nil
|
||||
return title, nil
|
||||
}
|
||||
|
||||
func (p *Server) regenerateChatTitleWithStore(
|
||||
@@ -2420,31 +2311,22 @@ func (p *Server) regenerateChatTitleWithStore(
|
||||
store database.Store,
|
||||
chat database.Chat,
|
||||
) (database.Chat, error) {
|
||||
result, err := p.generateManualTitleCandidate(ctx, store, chat)
|
||||
title, err := p.generateManualTitleCandidate(ctx, store, chat)
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
if !result.hasMessages {
|
||||
if title == "" {
|
||||
return chat, nil
|
||||
}
|
||||
|
||||
recordCtx, recordCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer recordCancel()
|
||||
// Generation already happened; don't let a client disconnect drop the
|
||||
// title write.
|
||||
persistCtx, persistCancel := context.WithTimeout(context.WithoutCancel(ctx), manualTitlePersistTimeout)
|
||||
defer persistCancel()
|
||||
|
||||
updatedChat, wroteTitle, recordErr := recordManualTitleUsage(
|
||||
recordCtx,
|
||||
store,
|
||||
chat,
|
||||
result.modelConfig,
|
||||
result.usage,
|
||||
result.activeAPIKeyID,
|
||||
result.title,
|
||||
)
|
||||
if recordErr != nil {
|
||||
if result.title != "" {
|
||||
return database.Chat{}, xerrors.Errorf("record manual title usage and update chat title: %w", recordErr)
|
||||
}
|
||||
return database.Chat{}, xerrors.Errorf("record manual title usage: %w", recordErr)
|
||||
updatedChat, wroteTitle, err := persistManualTitle(persistCtx, store, chat, title)
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("update chat title: %w", err)
|
||||
}
|
||||
// Publish only when this regeneration wrote the title. When a
|
||||
// concurrent rename won the race, the rename path already published
|
||||
@@ -2754,116 +2636,29 @@ func mergeManualTitleMessages(
|
||||
return merged
|
||||
}
|
||||
|
||||
func fantasyUsageToChatMessageUsage(usage fantasy.Usage) codersdk.ChatMessageUsage {
|
||||
var chatUsage codersdk.ChatMessageUsage
|
||||
if usage.InputTokens != 0 {
|
||||
chatUsage.InputTokens = ptr.Ref(usage.InputTokens)
|
||||
}
|
||||
if usage.OutputTokens != 0 {
|
||||
chatUsage.OutputTokens = ptr.Ref(usage.OutputTokens)
|
||||
}
|
||||
if usage.ReasoningTokens != 0 {
|
||||
chatUsage.ReasoningTokens = ptr.Ref(usage.ReasoningTokens)
|
||||
}
|
||||
if usage.CacheCreationTokens != 0 {
|
||||
chatUsage.CacheCreationTokens = ptr.Ref(usage.CacheCreationTokens)
|
||||
}
|
||||
if usage.CacheReadTokens != 0 {
|
||||
chatUsage.CacheReadTokens = ptr.Ref(usage.CacheReadTokens)
|
||||
}
|
||||
return chatUsage
|
||||
}
|
||||
|
||||
// recordManualTitleUsage stores token accounting for a manual title
|
||||
// generation and, when newTitle is set, persists it only if the chat
|
||||
// title still matches the caller's snapshot. The returned bool reports
|
||||
// whether the title was actually written; it is false when newTitle is
|
||||
// empty, when a concurrent writer changed the title first, or when
|
||||
// newTitle matches the current title.
|
||||
func recordManualTitleUsage(
|
||||
// persistManualTitle writes newTitle only if the chat title still
|
||||
// matches the caller's snapshot. The returned bool reports whether the
|
||||
// title was actually written; it is false when a concurrent writer
|
||||
// changed the title first or when newTitle matches the current title.
|
||||
// Token usage for manual title generation is not recorded here; AI
|
||||
// Gateway tracks it independently, and writing to chat_messages outside
|
||||
// the chatstate state machine would break in-flight task fences.
|
||||
func persistManualTitle(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chat database.Chat,
|
||||
modelConfig database.ChatModelConfig,
|
||||
usage fantasy.Usage,
|
||||
activeAPIKeyID string,
|
||||
newTitle string,
|
||||
) (database.Chat, bool, error) {
|
||||
hasUsage := usage != (fantasy.Usage{})
|
||||
if !hasUsage && newTitle == "" {
|
||||
return chat, false, nil
|
||||
}
|
||||
|
||||
var totalCostMicros *int64
|
||||
if hasUsage {
|
||||
callConfig := codersdk.ChatModelCallConfig{}
|
||||
if len(modelConfig.Options) > 0 {
|
||||
if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil {
|
||||
return database.Chat{}, false, xerrors.Errorf("parse model call config: %w", err)
|
||||
}
|
||||
}
|
||||
totalCostMicros = chatcost.CalculateTotalCostMicros(
|
||||
fantasyUsageToChatMessageUsage(usage),
|
||||
callConfig.Cost,
|
||||
)
|
||||
}
|
||||
|
||||
// Use a valid empty JSON array for the content column.
|
||||
// MarshalParts returns a null NullRawMessage for empty
|
||||
// slices, which becomes an empty string that PostgreSQL
|
||||
// rejects as invalid JSON.
|
||||
content := "[]"
|
||||
|
||||
updatedChat := chat
|
||||
wroteTitle := false
|
||||
err := store.InTx(func(tx database.Store) error {
|
||||
lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("lock chat for manual title usage: %w", err)
|
||||
return xerrors.Errorf("lock chat for manual title persist: %w", err)
|
||||
}
|
||||
updatedChat = lockedChat
|
||||
wroteTitle = false
|
||||
if hasUsage {
|
||||
messages, err := tx.InsertChatMessages(ctx, database.InsertChatMessagesParams{
|
||||
ChatID: chat.ID,
|
||||
CreatedBy: []uuid.UUID{chat.OwnerID},
|
||||
APIKeyID: []string{activeAPIKeyID},
|
||||
ModelConfigID: []uuid.UUID{modelConfig.ID},
|
||||
ReasoningEffort: []string{""},
|
||||
Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant},
|
||||
Content: []string{content},
|
||||
ContentVersion: []int16{chatprompt.CurrentContentVersion},
|
||||
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityModel},
|
||||
InputTokens: []int64{usage.InputTokens},
|
||||
OutputTokens: []int64{usage.OutputTokens},
|
||||
TotalTokens: []int64{usage.TotalTokens},
|
||||
ReasoningTokens: []int64{usage.ReasoningTokens},
|
||||
CacheCreationTokens: []int64{usage.CacheCreationTokens},
|
||||
CacheReadTokens: []int64{usage.CacheReadTokens},
|
||||
ContextLimit: []int64{modelConfig.ContextLimit},
|
||||
Compressed: []bool{false},
|
||||
TotalCostMicros: []int64{ptr.NilToDefault(totalCostMicros, 0)},
|
||||
RuntimeMs: []int64{0},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert manual title usage message: %w", err)
|
||||
}
|
||||
if len(messages) != 1 {
|
||||
return xerrors.Errorf("expected 1 manual title usage message, got %d", len(messages))
|
||||
}
|
||||
if err := tx.SoftDeleteChatMessageByID(ctx, messages[0].ID); err != nil {
|
||||
return xerrors.Errorf("soft delete manual title usage message: %w", err)
|
||||
}
|
||||
if lockedChat.LastModelConfigID != modelConfig.ID {
|
||||
if _, err := tx.UpdateChatLastModelConfigByID(ctx, database.UpdateChatLastModelConfigByIDParams{
|
||||
ID: chat.ID,
|
||||
LastModelConfigID: lockedChat.LastModelConfigID,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("restore chat model config after manual title usage: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if newTitle != "" && lockedChat.Title == chat.Title && newTitle != lockedChat.Title {
|
||||
if lockedChat.Title == chat.Title && newTitle != lockedChat.Title {
|
||||
updatedChat, err = tx.UpdateChatByID(ctx, database.UpdateChatByIDParams{
|
||||
ID: chat.ID,
|
||||
Title: newTitle,
|
||||
|
||||
@@ -895,15 +895,6 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
)
|
||||
|
||||
usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(chat, nil)
|
||||
usageTx.EXPECT().InsertChatMessages(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatMessagesParams{})).DoAndReturn(
|
||||
func(_ context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) {
|
||||
require.Equal(t, []uuid.UUID{ownerID}, arg.CreatedBy)
|
||||
require.Equal(t, []uuid.UUID{modelConfigID}, arg.ModelConfigID)
|
||||
require.Equal(t, []string{"[]"}, arg.Content)
|
||||
return []database.ChatMessage{{ID: 91}}, nil
|
||||
},
|
||||
)
|
||||
usageTx.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), int64(91)).Return(nil)
|
||||
usageTx.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{
|
||||
ID: chatID,
|
||||
Title: wantTitle,
|
||||
@@ -924,7 +915,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// With no request-level locking, recordManualTitleUsage's re-read under
|
||||
// With no request-level locking, persistManualTitle's re-read under
|
||||
// GetChatByIDForUpdate is the only protection against clobbering a title
|
||||
// that changed while the model call ran. The strict mock has no
|
||||
// UpdateChatByID expectation, so any persist attempt fails the test.
|
||||
@@ -1048,8 +1039,6 @@ func TestRegenerateChatTitle_SkipsPersistWhenTitleChangedConcurrently(t *testing
|
||||
)
|
||||
|
||||
usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(landedChat, nil)
|
||||
usageTx.EXPECT().InsertChatMessages(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatMessagesParams{})).Return([]database.ChatMessage{{ID: 91}}, nil)
|
||||
usageTx.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), int64(91)).Return(nil)
|
||||
|
||||
gotChat, err := server.RegenerateChatTitle(ctx, chat)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -8446,6 +8446,8 @@ func TestProposeChatTitle_DebugRun(t *testing.T) {
|
||||
require.Equal(t, message.ID, runs[0].HistoryTipMessageID.Int64)
|
||||
}
|
||||
if !tt.wantErr {
|
||||
// Title generation must not write accounting rows to
|
||||
// chat_messages; usage is tracked by AI Gateway.
|
||||
var usageMessages int
|
||||
err = rawDB.QueryRowContext(
|
||||
ctx,
|
||||
@@ -8453,7 +8455,7 @@ func TestProposeChatTitle_DebugRun(t *testing.T) {
|
||||
chat.ID,
|
||||
).Scan(&usageMessages)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, usageMessages)
|
||||
require.Equal(t, 0, usageMessages)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -919,7 +919,7 @@ func generateManualTitle(
|
||||
pasteText map[uuid.UUID]string,
|
||||
fallbackModel fantasy.LanguageModel,
|
||||
providerOptions fantasy.ProviderOptions,
|
||||
) (string, fantasy.Usage, error) {
|
||||
) (string, error) {
|
||||
turns := extractManualTitleTurns(messages, pasteText)
|
||||
selected := selectManualTitleTurnIndexes(turns)
|
||||
|
||||
@@ -927,7 +927,7 @@ func generateManualTitle(
|
||||
return turn.role == string(database.ChatMessageRoleUser)
|
||||
})
|
||||
if firstUserIndex == -1 {
|
||||
return "", fantasy.Usage{}, nil
|
||||
return "", nil
|
||||
}
|
||||
firstUserText := truncateRunes(turns[firstUserIndex].text, maxLatestUserMessageRunes)
|
||||
|
||||
@@ -946,7 +946,7 @@ func generateManualTitle(
|
||||
userInput = strings.TrimSpace(firstUserText)
|
||||
}
|
||||
|
||||
title, usage, err := generateStructuredTitleWithUsage(
|
||||
title, _, err := generateStructuredTitleWithUsage(
|
||||
titleCtx,
|
||||
fallbackModel,
|
||||
providerOptions,
|
||||
@@ -954,10 +954,10 @@ func generateManualTitle(
|
||||
userInput,
|
||||
)
|
||||
if err != nil {
|
||||
return "", usage, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return title, usage, nil
|
||||
return title, nil
|
||||
}
|
||||
|
||||
const turnStatusLabelPrompt = "You write compact chat status labels for a sidebar or push notification. " +
|
||||
|
||||
@@ -705,7 +705,7 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
title, _, err := generateManualTitle(
|
||||
title, err := generateManualTitle(
|
||||
context.Background(),
|
||||
messages,
|
||||
nil,
|
||||
@@ -743,7 +743,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := generateManualTitle(
|
||||
_, err := generateManualTitle(
|
||||
context.Background(),
|
||||
messages,
|
||||
nil,
|
||||
@@ -753,7 +753,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T) {
|
||||
func Test_generateManualTitle_ErrorsOnEmptyNormalizedTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
messages := []database.ChatMessage{
|
||||
@@ -778,7 +778,7 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T)
|
||||
},
|
||||
}
|
||||
|
||||
_, usage, err := generateManualTitle(
|
||||
_, err := generateManualTitle(
|
||||
context.Background(),
|
||||
messages,
|
||||
nil,
|
||||
@@ -786,9 +786,6 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T)
|
||||
nil,
|
||||
)
|
||||
require.ErrorContains(t, err, "generated title was empty")
|
||||
require.Equal(t, int64(11), usage.InputTokens)
|
||||
require.Equal(t, int64(7), usage.OutputTokens)
|
||||
require.Equal(t, int64(18), usage.TotalTokens)
|
||||
}
|
||||
|
||||
func Test_selectPreferredConfiguredShortTextModelConfig(t *testing.T) {
|
||||
|
||||
@@ -642,15 +642,13 @@ func TestGenerateManualTitleCandidate_ActiveAPIKeyIDFallback(t *testing.T) {
|
||||
|
||||
server := titleOverrideTestServer(db, logger)
|
||||
server.aibridgeTransportFactory = aibridgeTestFactoryPointer(factory)
|
||||
result, err := server.generateManualTitleCandidate(ctx, db, chat)
|
||||
title, err := server.generateManualTitleCandidate(ctx, db, chat)
|
||||
if tt.wantErrContains != "" {
|
||||
require.ErrorContains(t, err, tt.wantErrContains)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wantTitle, result.title)
|
||||
require.True(t, result.hasMessages)
|
||||
require.Equal(t, tt.wantAPIKeyID, result.activeAPIKeyID)
|
||||
require.Equal(t, wantTitle, title)
|
||||
require.Equal(t, tt.wantAPIKeyID, testutil.RequireReceive(ctx, t, seenAPIKeyID))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user