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:
Ethan
2026-07-13 16:50:19 +10:00
committed by GitHub
parent 0f11673a74
commit ea4554025e
8 changed files with 123 additions and 270 deletions
+72
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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,
+1 -12
View File
@@ -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)
+3 -1
View File
@@ -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)
}
})
}
+5 -5
View File
@@ -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. " +
+4 -7
View File
@@ -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))
})
}