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()