feat: show agent turn summary in agents sidebar (#24942)

Persists the agent-generated turn-end summary on `chats` and shows it as
the Agents sidebar subtitle when present, falling back to the model
name. Errors still take precedence.

> Mux is acting on Mike's behalf.

## What changes

**Storage.** New nullable `last_turn_summary` column on `chats`
(migration `000486`). New `UpdateChatLastTurnSummary` query normalizes
blank/whitespace input to `NULL`, preserves `updated_at` (so the chat
does not jump to the top of the sidebar on summary writes), and uses an
`expected_updated_at` stale-write guard so an older async summary cannot
overwrite a newer turn.

**Backend.** `coderd/x/chatd/chatd.go` decouples summary generation from
webpush. Generated summaries persist for completed parent turns even
when webpush is unconfigured or has no subscriptions. The same generated
text is reused as the webpush body when webpush is configured, so the
summary model is not called twice. Generic fallback push text is no
longer persisted; it clears any stale summary instead.
Error/interrupt/pending-action terminal paths clear `last_turn_summary`
for the latest turn.

**Frontend.** `AgentsSidebar.tsx` subtitle priority is now `errorReason
|| lastTurnSummary || modelName`, normalized via the existing
`asNonEmptyString` helper from `blockUtils.ts`.

## Tests

- `TestUpdateChatLastTurnSummary` (database): success,
whitespace-to-NULL, stale guard rejects, `updated_at` preserved.
- `TestUpdateLastTurnSummaryRejectsStaleWrites` (chatd internal): direct
stale-`expected_updated_at` test.
- `TestSuccessfulChatPersistsTurnSummaryWithoutWebPush`: persistence
works without webpush subscriptions.
- `TestSuccessfulChatSendsWebPushWithSummary`: same generated text
drives both DB and push body.
-
`TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText`:
fallback text is not persisted.
- `TestErroredChatClearsLastTurnSummaryAndSendsWebPush`: error path
clears the field.
- `TestInterruptChatDoesNotSendWebPushNotification`: interrupt path
clears the field, no push fires.
- `AgentsSidebar.test.tsx`: subtitle priority for summary-present,
error-wins, no-summary fallback, whitespace fallback.
- `AgentsSidebar.stories.tsx`: `ChatWithTurnSummary` and
`ChatWithTurnSummaryAndError`.

## Notes

- No backfill. Existing chats keep showing the model name until their
next turn completes.
- Parent chats only in this iteration; the field is rendered on any
`Chat` if a future change extends generation to children.
- Decoupling generation from webpush adds quickgen model calls for
completed parent turns that previously skipped generation when no
subscriptions existed. Existing parent-only, assistant-text-present,
`PushSummaryModel` configured, and bounded-timeout gates keep this
behavior bounded.
This commit is contained in:
Michael Suchacz
2026-05-06 16:43:35 +02:00
committed by GitHub
parent 369a191972
commit 0bfb9f6f13
40 changed files with 1313 additions and 115 deletions
+5
View File
@@ -15497,6 +15497,9 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"last_turn_summary": {
"type": "string"
},
"mcp_server_ids": {
"type": "array",
"items": {
@@ -16357,6 +16360,7 @@ const docTemplate = `{
"type": "string",
"enum": [
"status_change",
"summary_change",
"title_change",
"created",
"deleted",
@@ -16365,6 +16369,7 @@ const docTemplate = `{
],
"x-enum-varnames": [
"ChatWatchEventKindStatusChange",
"ChatWatchEventKindSummaryChange",
"ChatWatchEventKindTitleChange",
"ChatWatchEventKindCreated",
"ChatWatchEventKindDeleted",
+5
View File
@@ -13938,6 +13938,9 @@
"type": "string",
"format": "uuid"
},
"last_turn_summary": {
"type": "string"
},
"mcp_server_ids": {
"type": "array",
"items": {
@@ -14769,6 +14772,7 @@
"type": "string",
"enum": [
"status_change",
"summary_change",
"title_change",
"created",
"deleted",
@@ -14777,6 +14781,7 @@
],
"x-enum-varnames": [
"ChatWatchEventKindStatusChange",
"ChatWatchEventKindSummaryChange",
"ChatWatchEventKindTitleChange",
"ChatWatchEventKindCreated",
"ChatWatchEventKindDeleted",
+3
View File
@@ -1667,6 +1667,9 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
ClientType: codersdk.ChatClientType(c.ClientType),
LastError: lastError,
}
if c.LastTurnSummary.Valid {
chat.LastTurnSummary = &c.LastTurnSummary.String
}
if c.PlanMode.Valid {
chat.PlanMode = codersdk.ChatPlanMode(c.PlanMode.ChatPlanMode)
}
+1
View File
@@ -941,6 +941,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
Status: database.ChatStatusRunning,
ClientType: database.ChatClientTypeUi,
LastError: pqtype.NullRawMessage{RawMessage: lastErrorRaw, Valid: true},
LastTurnSummary: sql.NullString{String: "turn completed", Valid: true},
CreatedAt: now,
UpdatedAt: now,
Archived: true,
+11
View File
@@ -6308,6 +6308,17 @@ func (q *querier) UpdateChatLastReadMessageID(ctx context.Context, arg database.
return q.db.UpdateChatLastReadMessageID(ctx, arg)
}
func (q *querier) UpdateChatLastTurnSummary(ctx context.Context, arg database.UpdateChatLastTurnSummaryParams) (int64, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return 0, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return 0, err
}
return q.db.UpdateChatLastTurnSummary(ctx, arg)
}
func (q *querier) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
+11
View File
@@ -1532,6 +1532,17 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatLastTurnSummary", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatLastTurnSummaryParams{
ID: chat.ID,
ExpectedUpdatedAt: chat.UpdatedAt,
LastTurnSummary: sql.NullString{String: "resolved the issue", Valid: true},
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatLastTurnSummary(gomock.Any(), arg).Return(int64(1), nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1))
}))
s.Run("UpdateChatLastReadMessageID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatLastReadMessageIDParams{
@@ -4544,6 +4544,14 @@ func (m queryMetricsStore) UpdateChatLastReadMessageID(ctx context.Context, arg
return r0
}
func (m queryMetricsStore) UpdateChatLastTurnSummary(ctx context.Context, arg database.UpdateChatLastTurnSummaryParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatLastTurnSummary(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatLastTurnSummary").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatLastTurnSummary").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatMCPServerIDs(ctx, arg)
+15
View File
@@ -8596,6 +8596,21 @@ func (mr *MockStoreMockRecorder) UpdateChatLastReadMessageID(ctx, arg any) *gomo
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastReadMessageID", reflect.TypeOf((*MockStore)(nil).UpdateChatLastReadMessageID), ctx, arg)
}
// UpdateChatLastTurnSummary mocks base method.
func (m *MockStore) UpdateChatLastTurnSummary(ctx context.Context, arg database.UpdateChatLastTurnSummaryParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatLastTurnSummary", ctx, arg)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatLastTurnSummary indicates an expected call of UpdateChatLastTurnSummary.
func (mr *MockStoreMockRecorder) UpdateChatLastTurnSummary(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastTurnSummary", reflect.TypeOf((*MockStore)(nil).UpdateChatLastTurnSummary), ctx, arg)
}
// UpdateChatMCPServerIDs mocks base method.
func (m *MockStore) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) {
m.ctrl.T.Helper()
+1
View File
@@ -1451,6 +1451,7 @@ CREATE TABLE chats (
organization_id uuid NOT NULL,
plan_mode chat_plan_mode,
client_type chat_client_type DEFAULT 'api'::chat_client_type NOT NULL,
last_turn_summary text,
CONSTRAINT chats_pin_order_archived_check CHECK (((pin_order = 0) OR (archived = false))),
CONSTRAINT chats_pin_order_parent_check CHECK (((pin_order = 0) OR (parent_chat_id IS NULL)))
);
@@ -0,0 +1 @@
ALTER TABLE chats DROP COLUMN last_turn_summary;
@@ -0,0 +1 @@
ALTER TABLE chats ADD COLUMN last_turn_summary TEXT;
+1
View File
@@ -802,6 +802,7 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
&i.Chat.OrganizationID,
&i.Chat.PlanMode,
&i.Chat.ClientType,
&i.Chat.LastTurnSummary,
&i.HasUnread); err != nil {
return nil, err
}
+1
View File
@@ -4380,6 +4380,7 @@ type Chat struct {
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
ClientType ChatClientType `db:"client_type" json:"client_type"`
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
}
type ChatDebugRun struct {
+10
View File
@@ -1114,6 +1114,16 @@ type sqlcQuerier interface {
// Updates the last read message ID for a chat. This is used to track
// which messages the owner has seen, enabling unread indicators.
UpdateChatLastReadMessageID(ctx context.Context, arg UpdateChatLastReadMessageIDParams) error
// Updates the cached last completed turn summary for sidebar display.
// Empty or whitespace-only summaries are stored as NULL here so direct
// query callers cannot accidentally persist blank sidebar text.
// This intentionally preserves updated_at. The staleness guard relies on
// every new-turn query, such as UpdateChatStatus and AcquireChats, bumping
// updated_at. Future chat-field updates that do not bump updated_at can let
// stale summaries persist. If this query ever bumps updated_at, later
// goroutine summary writes will be rejected as stale.
// Two summary workers using the same freshness marker are last-write-wins.
UpdateChatLastTurnSummary(ctx context.Context, arg UpdateChatLastTurnSummaryParams) (int64, error)
UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error)
UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error)
UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error)
+105
View File
@@ -11448,6 +11448,111 @@ func TestChatLabels(t *testing.T) {
})
}
func TestUpdateChatLastTurnSummary(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
sqlDB := testSQLDB(t)
err := migrations.Up(sqlDB)
require.NoError(t, err)
db := database.New(sqlDB)
ctx := testutil.Context(t, testutil.WaitMedium)
owner := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: owner.ID, OrganizationID: org.ID})
_, err = db.InsertChatProvider(ctx, database.InsertChatProviderParams{
Provider: "openai",
DisplayName: "OpenAI",
APIKey: "test-key",
Enabled: true,
CentralApiKeyEnabled: true,
})
require.NoError(t, err)
modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
Provider: "openai",
Model: "test-model",
DisplayName: "Test Model",
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
Enabled: true,
IsDefault: true,
ContextLimit: 128000,
CompressionThreshold: 80,
Options: json.RawMessage(`{}`),
})
require.NoError(t, err)
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "summary-chat",
})
require.NoError(t, err)
affected, err := db.UpdateChatLastTurnSummary(ctx, database.UpdateChatLastTurnSummaryParams{
ID: chat.ID,
ExpectedUpdatedAt: chat.UpdatedAt,
LastTurnSummary: sql.NullString{String: "resolved the issue", Valid: true},
})
require.NoError(t, err)
require.EqualValues(t, 1, affected)
fetched, err := db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, sql.NullString{String: "resolved the issue", Valid: true}, fetched.LastTurnSummary)
require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt)
affected, err = db.UpdateChatLastTurnSummary(ctx, database.UpdateChatLastTurnSummaryParams{
ID: chat.ID,
ExpectedUpdatedAt: chat.UpdatedAt,
LastTurnSummary: sql.NullString{String: " \n\t ", Valid: true},
})
require.NoError(t, err)
require.EqualValues(t, 1, affected)
fetched, err = db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.False(t, fetched.LastTurnSummary.Valid)
require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt)
affected, err = db.UpdateChatLastTurnSummary(ctx, database.UpdateChatLastTurnSummaryParams{
ID: chat.ID,
ExpectedUpdatedAt: chat.UpdatedAt,
LastTurnSummary: sql.NullString{String: "fresh summary", Valid: true},
})
require.NoError(t, err)
require.EqualValues(t, 1, affected)
advancedUpdatedAt := chat.UpdatedAt.Add(time.Second)
_, err = db.UpdateChatStatusPreserveUpdatedAt(ctx, database.UpdateChatStatusPreserveUpdatedAtParams{
ID: chat.ID,
Status: database.ChatStatusRunning,
UpdatedAt: advancedUpdatedAt,
})
require.NoError(t, err)
affected, err = db.UpdateChatLastTurnSummary(ctx, database.UpdateChatLastTurnSummaryParams{
ID: chat.ID,
ExpectedUpdatedAt: chat.UpdatedAt,
LastTurnSummary: sql.NullString{String: "stale summary", Valid: true},
})
require.NoError(t, err)
require.Zero(t, affected)
fetched, err = db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, sql.NullString{String: "fresh summary", Valid: true}, fetched.LastTurnSummary)
require.Equal(t, advancedUpdatedAt, fetched.UpdatedAt)
}
func TestDeleteChatDebugDataAfterMessageIDIncludesTriggeredRuns(t *testing.T) {
t.Parallel()
+84 -26
View File
@@ -5120,7 +5120,7 @@ WHERE
$3::int
)
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
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, last_turn_summary
`
type AcquireChatsParams struct {
@@ -5168,6 +5168,7 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) (
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
); err != nil {
return nil, err
}
@@ -5306,9 +5307,9 @@ WITH chats AS (
UPDATE chats
SET archived = true, pin_order = 0, updated_at = NOW()
WHERE id = $1::uuid OR root_chat_id = $1::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
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, last_turn_summary
)
SELECT 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
SELECT 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, last_turn_summary
FROM chats
ORDER BY (id = $1::uuid) DESC, created_at ASC, id ASC
`
@@ -5350,6 +5351,7 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat,
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
); err != nil {
return nil, err
}
@@ -5399,10 +5401,10 @@ archived AS (
FROM to_archive t
WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children
AND c.archived = false
RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type
RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary
)
SELECT
a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.last_injected_context, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type,
a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.last_injected_context, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary,
-- Children inherit their root's activity so last_activity_at is never null.
COALESCE(
t.last_activity_at,
@@ -5447,6 +5449,7 @@ type AutoArchiveInactiveChatsRow struct {
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
ClientType ChatClientType `db:"client_type" json:"client_type"`
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"`
}
@@ -5492,6 +5495,7 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.LastActivityAt,
); err != nil {
return nil, err
@@ -5642,7 +5646,7 @@ func (q *sqlQuerier) DeleteOldChats(ctx context.Context, arg DeleteOldChatsParam
}
const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many
SELECT 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
SELECT 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, last_turn_summary
FROM chats
WHERE agent_id = $1::uuid
AND archived = false
@@ -5690,6 +5694,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
); err != nil {
return nil, err
}
@@ -5706,7 +5711,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
const getChatByID = `-- name: GetChatByID :one
SELECT
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
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, last_turn_summary
FROM
chats
WHERE
@@ -5744,12 +5749,13 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one
SELECT 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 FROM chats WHERE id = $1::uuid FOR UPDATE
SELECT 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, last_turn_summary FROM chats WHERE id = $1::uuid FOR UPDATE
`
func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) {
@@ -5783,6 +5789,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -6890,7 +6897,7 @@ func (q *sqlQuerier) GetChatUsageLimitUserOverride(ctx context.Context, userID u
const getChats = `-- name: GetChats :many
SELECT
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type,
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type, chats.last_turn_summary,
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats.id
@@ -7011,6 +7018,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
&i.Chat.OrganizationID,
&i.Chat.PlanMode,
&i.Chat.ClientType,
&i.Chat.LastTurnSummary,
&i.HasUnread,
); err != nil {
return nil, err
@@ -7027,7 +7035,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
}
const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many
SELECT 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
SELECT 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, last_turn_summary
FROM chats
WHERE archived = false
AND workspace_id = ANY($1::uuid[])
@@ -7071,6 +7079,7 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
); err != nil {
return nil, err
}
@@ -7155,7 +7164,7 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time
const getChildChatsByParentIDs = `-- name: GetChildChatsByParentIDs :many
SELECT
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type,
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type, chats.last_turn_summary,
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats.id
@@ -7227,6 +7236,7 @@ func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildC
&i.Chat.OrganizationID,
&i.Chat.PlanMode,
&i.Chat.ClientType,
&i.Chat.LastTurnSummary,
&i.HasUnread,
); err != nil {
return nil, err
@@ -7293,7 +7303,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh
const getStaleChats = `-- name: GetStaleChats :many
SELECT
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
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, last_turn_summary
FROM
chats
WHERE
@@ -7344,6 +7354,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
); err != nil {
return nil, err
}
@@ -7457,7 +7468,7 @@ INSERT INTO chats (
$16::chat_client_type
)
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
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, last_turn_summary
`
type InsertChatParams struct {
@@ -7527,6 +7538,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8062,9 +8074,9 @@ WITH chats AS (
archived = false,
updated_at = NOW()
WHERE id = $1::uuid OR root_chat_id = $1::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
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, last_turn_summary
)
SELECT 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
SELECT 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, last_turn_summary
FROM chats
ORDER BY (id = $1::uuid) DESC, created_at ASC, id ASC
`
@@ -8110,6 +8122,7 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
); err != nil {
return nil, err
}
@@ -8190,7 +8203,7 @@ UPDATE chats SET
updated_at = NOW()
WHERE
id = $3::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
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, last_turn_summary
`
type UpdateChatBuildAgentBindingParams struct {
@@ -8230,6 +8243,7 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8243,7 +8257,7 @@ SET
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
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, last_turn_summary
`
type UpdateChatByIDParams struct {
@@ -8282,6 +8296,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8340,7 +8355,7 @@ SET
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
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, last_turn_summary
`
type UpdateChatLabelsByIDParams struct {
@@ -8379,6 +8394,7 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8388,7 +8404,7 @@ UPDATE chats SET
last_injected_context = $1::jsonb
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
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, last_turn_summary
`
type UpdateChatLastInjectedContextParams struct {
@@ -8431,6 +8447,7 @@ func (q *sqlQuerier) UpdateChatLastInjectedContext(ctx context.Context, arg Upda
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8444,7 +8461,7 @@ SET
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
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, last_turn_summary
`
type UpdateChatLastModelConfigByIDParams struct {
@@ -8483,6 +8500,7 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8505,6 +8523,40 @@ func (q *sqlQuerier) UpdateChatLastReadMessageID(ctx context.Context, arg Update
return err
}
const updateChatLastTurnSummary = `-- name: UpdateChatLastTurnSummary :execrows
UPDATE chats
SET
last_turn_summary = NULLIF(REGEXP_REPLACE(
$1::text, '^[[:space:]]+|[[:space:]]+$', '', 'g'
), '')
WHERE
id = $2::uuid
AND updated_at = $3::timestamptz
`
type UpdateChatLastTurnSummaryParams struct {
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
ID uuid.UUID `db:"id" json:"id"`
ExpectedUpdatedAt time.Time `db:"expected_updated_at" json:"expected_updated_at"`
}
// Updates the cached last completed turn summary for sidebar display.
// Empty or whitespace-only summaries are stored as NULL here so direct
// query callers cannot accidentally persist blank sidebar text.
// This intentionally preserves updated_at. The staleness guard relies on
// every new-turn query, such as UpdateChatStatus and AcquireChats, bumping
// updated_at. Future chat-field updates that do not bump updated_at can let
// stale summaries persist. If this query ever bumps updated_at, later
// goroutine summary writes will be rejected as stale.
// Two summary workers using the same freshness marker are last-write-wins.
func (q *sqlQuerier) UpdateChatLastTurnSummary(ctx context.Context, arg UpdateChatLastTurnSummaryParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updateChatLastTurnSummary, arg.LastTurnSummary, arg.ID, arg.ExpectedUpdatedAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const updateChatMCPServerIDs = `-- name: UpdateChatMCPServerIDs :one
UPDATE
chats
@@ -8514,7 +8566,7 @@ SET
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
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, last_turn_summary
`
type UpdateChatMCPServerIDsParams struct {
@@ -8553,6 +8605,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8684,7 +8737,7 @@ SET
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
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, last_turn_summary
`
type UpdateChatPlanModeByIDParams struct {
@@ -8723,6 +8776,7 @@ func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatP
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8740,7 +8794,7 @@ SET
WHERE
id = $6::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
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, last_turn_summary
`
type UpdateChatStatusParams struct {
@@ -8790,6 +8844,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8807,7 +8862,7 @@ SET
WHERE
id = $7::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
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, last_turn_summary
`
type UpdateChatStatusPreserveUpdatedAtParams struct {
@@ -8859,6 +8914,7 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8874,7 +8930,7 @@ SET
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
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, last_turn_summary
`
type UpdateChatTitleByIDParams struct {
@@ -8913,6 +8969,7 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
@@ -8924,7 +8981,7 @@ UPDATE chats SET
agent_id = $3::uuid,
updated_at = NOW()
WHERE id = $4::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
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, last_turn_summary
`
type UpdateChatWorkspaceBindingParams struct {
@@ -8970,6 +9027,7 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
)
return i, err
}
+19
View File
@@ -632,6 +632,25 @@ WHERE
id = @id::uuid
RETURNING *;
-- name: UpdateChatLastTurnSummary :execrows
-- Updates the cached last completed turn summary for sidebar display.
-- Empty or whitespace-only summaries are stored as NULL here so direct
-- query callers cannot accidentally persist blank sidebar text.
-- This intentionally preserves updated_at. The staleness guard relies on
-- every new-turn query, such as UpdateChatStatus and AcquireChats, bumping
-- updated_at. Future chat-field updates that do not bump updated_at can let
-- stale summaries persist. If this query ever bumps updated_at, later
-- goroutine summary writes will be rejected as stale.
-- Two summary workers using the same freshness marker are last-write-wins.
UPDATE chats
SET
last_turn_summary = NULLIF(REGEXP_REPLACE(
sqlc.narg('last_turn_summary')::text, '^[[:space:]]+|[[:space:]]+$', '', 'g'
), '')
WHERE
id = @id::uuid
AND updated_at = @expected_updated_at::timestamptz;
-- name: UpdateChatMCPServerIDs :one
UPDATE
chats
+196 -48
View File
@@ -67,6 +67,7 @@ const (
instructionCacheTTL = 5 * time.Minute
workspaceDialValidationDelay = 5 * time.Second
workspaceMCPDiscoveryTimeout = 5 * time.Second
turnSummaryWriteTimeout = 5 * time.Second
// defaultDialTimeout matches the timeout used by ~8 other
// server-side AgentConn callers.
defaultDialTimeout = 30 * time.Second
@@ -5509,12 +5510,21 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
})
p.publishChatActionRequired(finishResult.updatedChat, runResult.PendingDynamicToolCalls)
}
if !wasInterrupted {
if wasInterrupted {
p.maybeClearLastTurnSummaryAsync(cleanupCtx, finishResult.updatedChat, logger)
} else {
lastErrorMessage := ""
if lastErrorPayload != nil {
lastErrorMessage = lastErrorPayload.Message
}
p.maybeSendPushNotification(cleanupCtx, finishResult.updatedChat, status, lastErrorMessage, runResult, logger)
p.maybeFinalizeTurnSummaryAndPush(
cleanupCtx,
finishResult.updatedChat,
status,
lastErrorMessage,
runResult,
logger,
)
}
}()
@@ -5537,6 +5547,7 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
logger.Info(ctx, "chat canceled during shutdown; returning to pending")
status = database.ChatStatusPending
lastErrorPayload = nil
wasInterrupted = true
return
}
logger.Error(ctx, "failed to process chat", slog.Error(err))
@@ -5567,6 +5578,7 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
logger.Info(ctx, "chat completed during shutdown; returning to pending")
status = database.ChatStatusPending
lastErrorPayload = nil
wasInterrupted = true
return
}
}
@@ -8251,12 +8263,9 @@ func parseDynamicToolNames(raw pqtype.NullRawMessage) (map[string]bool, error) {
return names, nil
}
// maybeSendPushNotification sends a web push notification when an
// agent chat reaches a terminal state. For errors it dispatches
// synchronously; for successful completions it spawns a goroutine
// that generates a short LLM summary before dispatching. The caller
// is responsible for skipping interrupted chats.
func (p *Server) maybeSendPushNotification(
// maybeFinalizeTurnSummaryAndPush updates the cached turn summary for
// parent chats and optionally sends a web push notification.
func (p *Server) maybeFinalizeTurnSummaryAndPush(
ctx context.Context,
chat database.Chat,
status database.ChatStatus,
@@ -8264,56 +8273,195 @@ func (p *Server) maybeSendPushNotification(
runResult runChatResult,
logger slog.Logger,
) {
if p.webpushDispatcher == nil || p.webpushDispatcher.PublicKey() == "" {
return
}
if chat.ParentChatID.Valid {
return
}
switch status {
case database.ChatStatusError:
pushBody := "Agent encountered an error."
if lastError != "" {
pushBody = lastError
}
p.dispatchPush(ctx, chat, pushBody, status, logger)
case database.ChatStatusWaiting:
// Generate a push notification summary asynchronously
// using a cheap LLM model. This avoids blocking the
// deferred cleanup path while still providing a
// meaningful notification body.
debugSvc := p.existingDebugService()
p.inflight.Add(1)
go func() {
defer p.inflight.Done()
pushCtx := context.WithoutCancel(ctx)
pushBody := "Agent has finished running."
assistantText := strings.TrimSpace(runResult.FinalAssistantText)
if assistantText != "" && runResult.PushSummaryModel != nil {
if summary := generatePushSummary(
pushCtx,
chat,
assistantText,
runResult.FallbackProvider,
runResult.FallbackModel,
runResult.PushSummaryModel,
runResult.ProviderKeys,
logger,
debugSvc,
runResult.TriggerMessageID,
runResult.HistoryTipMessageID,
); summary != "" {
pushBody = summary
}
}
p.finalizeSuccessfulTurnSummaryAndPush(ctx, chat, runResult, logger)
p.dispatchPush(pushCtx, chat, pushBody, status, logger)
}()
case database.ChatStatusPending:
p.finalizeSuccessfulTurnSummary(ctx, chat, runResult, logger)
case database.ChatStatusError:
p.clearLastTurnSummaryAsync(ctx, chat, logger)
if p.webpushConfigured() {
pushBody := "Agent encountered an error."
if lastError != "" {
pushBody = lastError
}
p.dispatchPush(ctx, chat, pushBody, status, logger)
}
case database.ChatStatusRequiresAction:
p.clearLastTurnSummaryAsync(ctx, chat, logger)
default:
// New statuses must be classified before they can safely
// preserve or finalize a cached turn summary.
p.clearLastTurnSummaryAsync(ctx, chat, logger)
}
}
func (p *Server) finalizeSuccessfulTurnSummary(
ctx context.Context,
chat database.Chat,
runResult runChatResult,
logger slog.Logger,
) {
p.finalizeSuccessfulTurnSummaryWithAfterFunc(ctx, chat, runResult, logger, func(context.Context, string) {})
}
func (p *Server) finalizeSuccessfulTurnSummaryAndPush(
ctx context.Context,
chat database.Chat,
runResult runChatResult,
logger slog.Logger,
) {
p.finalizeSuccessfulTurnSummaryWithAfterFunc(ctx, chat, runResult, logger, func(finalizeCtx context.Context, summary string) {
p.dispatchSuccessfulTurnPush(finalizeCtx, chat, summary, logger)
})
}
func (p *Server) finalizeSuccessfulTurnSummaryWithAfterFunc(
ctx context.Context,
chat database.Chat,
runResult runChatResult,
logger slog.Logger,
afterFinalize func(context.Context, string),
) {
debugSvc := p.existingDebugService()
// This helper runs during processChat cleanup, while processChat is
// still counted in p.inflight. Do not take inflightMu here because
// drainInflight holds it while waiting.
p.inflight.Go(func() {
finalizeCtx := context.WithoutCancel(ctx)
summary := ""
assistantText := strings.TrimSpace(runResult.FinalAssistantText)
if assistantText != "" && runResult.PushSummaryModel != nil {
summary = strings.TrimSpace(generatePushSummary(
finalizeCtx,
chat,
assistantText,
runResult.FallbackProvider,
runResult.FallbackModel,
runResult.PushSummaryModel,
runResult.ProviderKeys,
logger,
debugSvc,
runResult.TriggerMessageID,
runResult.HistoryTipMessageID,
))
}
shouldPersistSummary := summary != "" || chat.LastTurnSummary.Valid
if shouldPersistSummary {
p.updateLastTurnSummary(finalizeCtx, chat, chat.UpdatedAt, summary, logger)
}
afterFinalize(finalizeCtx, summary)
})
}
func (p *Server) dispatchSuccessfulTurnPush(
ctx context.Context,
chat database.Chat,
summary string,
logger slog.Logger,
) {
if !p.webpushConfigured() {
return
}
pushBody := "Agent has finished running."
if summary != "" {
pushBody = summary
}
p.dispatchPush(ctx, chat, pushBody, database.ChatStatusWaiting, logger)
}
func (p *Server) maybeClearLastTurnSummaryAsync(
ctx context.Context,
chat database.Chat,
logger slog.Logger,
) {
if chat.ParentChatID.Valid {
return
}
p.clearLastTurnSummaryAsync(ctx, chat, logger)
}
func (p *Server) clearLastTurnSummaryAsync(
ctx context.Context,
chat database.Chat,
logger slog.Logger,
) {
if !chat.LastTurnSummary.Valid {
return
}
// This helper runs during processChat cleanup, while processChat is
// still counted in p.inflight. Do not take inflightMu here because
// drainInflight holds it while waiting.
p.inflight.Go(func() {
p.updateLastTurnSummary(context.WithoutCancel(ctx), chat, chat.UpdatedAt, "", logger)
})
}
// updateLastTurnSummary writes the cached sidebar summary for a chat.
// Callers should pass a detached context because this method is used for
// best-effort background cache writes.
func (p *Server) updateLastTurnSummary(
ctx context.Context,
chat database.Chat,
expectedUpdatedAt time.Time,
summary string,
logger slog.Logger,
) {
summary = strings.TrimSpace(summary)
lastTurnSummary := sql.NullString{String: summary, Valid: summary != ""}
//nolint:gocritic // Narrow daemon access for best-effort summary cache writes.
updateCtx := dbauthz.AsChatd(ctx)
updateCtx, cancel := context.WithTimeout(updateCtx, turnSummaryWriteTimeout)
defer cancel()
affected, err := p.db.UpdateChatLastTurnSummary(updateCtx, database.UpdateChatLastTurnSummaryParams{
ID: chat.ID,
ExpectedUpdatedAt: expectedUpdatedAt,
LastTurnSummary: lastTurnSummary,
})
if err != nil {
logger.Warn(updateCtx, "failed to update chat turn summary",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
return
}
if affected == 0 {
if summary != "" {
logger.Info(updateCtx, "skipped stale chat turn summary update with non-empty summary",
slog.F("chat_id", chat.ID),
slog.F("summary_length", len(summary)),
slog.F("expected_updated_at", expectedUpdatedAt),
)
return
}
logger.Debug(updateCtx, "skipped stale chat turn summary update",
slog.F("chat_id", chat.ID),
slog.F("expected_updated_at", expectedUpdatedAt),
)
return
}
updatedChat := chat
updatedChat.LastTurnSummary = lastTurnSummary
p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindSummaryChange, nil)
}
func (p *Server) webpushConfigured() bool {
return p.webpushDispatcher != nil && p.webpushDispatcher.PublicKey() != ""
}
func (p *Server) dispatchPush(
ctx context.Context,
chat database.Chat,
+9 -1
View File
@@ -3442,7 +3442,11 @@ func TestProcessChat_IgnoresStaleControlNotification(t *testing.T) {
db.EXPECT().UpdateChatStatus(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, params database.UpdateChatStatusParams) (database.Chat, error) {
finalStatus = params.Status
return database.Chat{ID: chatID, Status: params.Status}, nil
return database.Chat{
ID: chatID,
Status: params.Status,
LastTurnSummary: sql.NullString{String: "previous summary", Valid: true},
}, nil
},
)
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(
@@ -3450,6 +3454,8 @@ func TestProcessChat_IgnoresStaleControlNotification(t *testing.T) {
nil,
)
db.EXPECT().UpdateChatLastTurnSummary(gomock.Any(), gomock.Any()).Return(int64(1), nil)
// resolveChatModel fails immediately — that's fine, we only
// need processChat to get past initialization without being
// interrupted by the stale notification.
@@ -3475,6 +3481,8 @@ func TestProcessChat_IgnoresStaleControlNotification(t *testing.T) {
// the status update itself races test teardown.
testutil.TryReceive(ctx, t, done)
WaitUntilIdleForTest(server)
// If the stale notification interrupted us, status would be
// "waiting" (the ErrInterrupted path). Since the gate blocked
// it, processChat reached runChat, which failed on model
+267 -17
View File
@@ -4047,6 +4047,93 @@ func TestPersistToolResultWithBinaryData(t *testing.T) {
require.True(t, foundToolResultInSecondCall, "expected second streamed model call to include execute tool output")
}
func TestRequiresActionChatClearsLastTurnSummary(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("Dynamic tool test")
}
return chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk(
"my_dynamic_tool",
`{"input":"hello world"}`,
),
)
})
mockPush := &mockWebpushDispatcher{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
server := chatd.New(chatd.Config{
Logger: logger,
Database: db,
ReplicaID: uuid.New(),
Pubsub: ps,
PendingChatAcquireInterval: 10 * time.Millisecond,
InFlightChatStaleAfter: testutil.WaitSuperLong,
WebpushDispatcher: mockPush,
})
t.Cleanup(func() {
require.NoError(t, server.Close())
})
user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL)
dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{
Name: "my_dynamic_tool",
Description: "A test dynamic tool.",
InputSchema: mcpgo.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"input": map[string]any{"type": "string"},
},
Required: []string{"input"},
},
}})
require.NoError(t, err)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "requires-action-summary-clear",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("Please call the dynamic tool."),
},
DynamicTools: dynamicToolsJSON,
})
require.NoError(t, err)
seedLastTurnSummary(ctx, t, db, chat, "previous summary")
server.Start()
var fromDB database.Chat
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
got, dbErr := db.GetChatByID(ctx, chat.ID)
if dbErr != nil {
return false
}
fromDB = got
if got.Status == database.ChatStatusError {
return true
}
return got.Status == database.ChatStatusRequiresAction &&
!got.LastTurnSummary.Valid
}, testutil.IntervalFast)
chatd.WaitUntilIdleForTest(server)
require.Equal(t, database.ChatStatusRequiresAction, fromDB.Status,
"expected requires_action, got %s (last_error=%q)",
fromDB.Status, string(fromDB.LastError.RawMessage))
require.False(t, fromDB.LastTurnSummary.Valid,
"requires action chats should clear cached turn summaries")
require.Equal(t, int32(0), mockPush.dispatchCount.Load(),
"expected no web push dispatch for a requires_action chat")
}
func TestDynamicToolCallPausesAndResumes(t *testing.T) {
t.Parallel()
@@ -5907,6 +5994,24 @@ func seedChatDependenciesWithProviderPolicy(
return user, org, providerConfig, model
}
func seedLastTurnSummary(
ctx context.Context,
t *testing.T,
db database.Store,
chat database.Chat,
summary string,
) {
t.Helper()
affected, err := db.UpdateChatLastTurnSummary(ctx, database.UpdateChatLastTurnSummaryParams{
ID: chat.ID,
ExpectedUpdatedAt: chat.UpdatedAt,
LastTurnSummary: sql.NullString{String: summary, Valid: true},
})
require.NoError(t, err)
require.Equal(t, int64(1), affected)
}
func waitForTerminalChatStatusEvent(
ctx context.Context,
t *testing.T,
@@ -6121,7 +6226,6 @@ func TestInterruptChatDoesNotSendWebPushNotification(t *testing.T) {
InFlightChatStaleAfter: testutil.WaitSuperLong,
WebpushDispatcher: mockPush,
})
server.Start()
t.Cleanup(func() {
require.NoError(t, server.Close())
})
@@ -6137,6 +6241,9 @@ func TestInterruptChatDoesNotSendWebPushNotification(t *testing.T) {
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
seedLastTurnSummary(ctx, t, db, chat, "previous summary")
server.Start()
// Wait for the chat to be picked up and start streaming.
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
@@ -6168,6 +6275,12 @@ func TestInterruptChatDoesNotSendWebPushNotification(t *testing.T) {
}
return fromDB.Status == database.ChatStatusWaiting && !fromDB.WorkerID.Valid
}, testutil.IntervalFast)
chatd.WaitUntilIdleForTest(server)
fromDB, err := db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.False(t, fromDB.LastTurnSummary.Valid,
"interrupted chats should clear cached turn summaries")
// Verify no web push notification was dispatched.
require.Equal(t, int32(0), mockPush.dispatchCount.Load(),
@@ -6435,7 +6548,7 @@ func TestSuccessfulChatSendsWebPushWithSummary(t *testing.T) {
user, org, model := seedChatDependencies(t, db)
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
_, err := server.CreateChat(ctx, chatd.CreateOptions{
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "summary-push-test",
@@ -6447,19 +6560,71 @@ func TestSuccessfulChatSendsWebPushWithSummary(t *testing.T) {
// The push notification is dispatched asynchronously after the
// chat finishes, so we poll for it rather than checking
// immediately after the status transitions to waiting.
var fromDB database.Chat
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
return mockPush.dispatchCount.Load() >= 1
var dbErr error
fromDB, dbErr = db.GetChatByID(ctx, chat.ID)
return dbErr == nil && mockPush.dispatchCount.Load() >= 1 && fromDB.LastTurnSummary.Valid
}, testutil.IntervalFast)
msg := mockPush.getLastMessage()
require.Equal(t, summaryText, msg.Body,
"push body should be the LLM-generated summary")
require.Equal(t, summaryText, fromDB.LastTurnSummary.String,
"last turn summary should be the LLM-generated summary")
require.Equal(t, fromDB.LastTurnSummary.String, msg.Body,
"push body should reuse the persisted generated summary")
require.NotEqual(t, "Agent has finished running.", msg.Body,
"push body should not use the default fallback text")
require.Equal(t, int32(1), nonStreamingRequests.Load(),
"expected exactly one non-streaming request for push summary generation")
}
func TestSuccessfulChatPersistsTurnSummaryWithoutWebPush(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const assistantText = "I fixed the bug and added regression coverage."
const summaryText = "Fixed the bug and added regression coverage."
var nonStreamingRequests atomic.Int32
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
nonStreamingRequests.Add(1)
return chattest.OpenAINonStreamingResponse(summaryText)
}
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks(assistantText)...,
)
})
server := newActiveTestServer(t, db, ps)
user, org, model := seedChatDependencies(t, db)
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "summary-no-webpush-test",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("do the thing")},
})
require.NoError(t, err)
var fromDB database.Chat
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
var dbErr error
fromDB, dbErr = db.GetChatByID(ctx, chat.ID)
return dbErr == nil && fromDB.LastTurnSummary.Valid
}, testutil.IntervalFast)
require.Equal(t, summaryText, fromDB.LastTurnSummary.String,
"summary should persist even when web push is unavailable")
require.Equal(t, int32(1), nonStreamingRequests.Load(),
"expected exactly one non-streaming request for summary generation")
}
func TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText(t *testing.T) {
t.Parallel()
@@ -6489,7 +6654,6 @@ func TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText(t
InFlightChatStaleAfter: testutil.WaitSuperLong,
WebpushDispatcher: mockPush,
})
server.Start()
t.Cleanup(func() {
require.NoError(t, server.Close())
})
@@ -6497,7 +6661,7 @@ func TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText(t
user, org, model := seedChatDependencies(t, db)
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
_, err := server.CreateChat(ctx, chatd.CreateOptions{
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "empty-summary-push-test",
@@ -6505,11 +6669,19 @@ func TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText(t
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("do the thing")},
})
require.NoError(t, err)
seedLastTurnSummary(ctx, t, db, chat, "previous summary")
server.Start()
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
return mockPush.dispatchCount.Load() >= 1
}, testutil.IntervalFast)
fromDB, err := db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.False(t, fromDB.LastTurnSummary.Valid,
"fallback push text should not be persisted")
msg := mockPush.getLastMessage()
require.Equal(t, "Agent has finished running.", msg.Body,
"push body should fall back when the final assistant text is empty")
@@ -6517,6 +6689,68 @@ func TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText(t
"push summary should not be requested when final assistant text has no usable text")
}
func TestErroredChatClearsLastTurnSummaryAndSendsWebPush(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
return chattest.OpenAIErrorResponse(http.StatusBadRequest, "invalid_request_error", "Bad request")
})
mockPush := &mockWebpushDispatcher{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
server := chatd.New(chatd.Config{
Logger: logger,
Database: db,
ReplicaID: uuid.New(),
Pubsub: ps,
PendingChatAcquireInterval: 10 * time.Millisecond,
InFlightChatStaleAfter: testutil.WaitSuperLong,
WebpushDispatcher: mockPush,
})
t.Cleanup(func() {
require.NoError(t, server.Close())
})
user, org, model := seedChatDependencies(t, db)
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "error-summary-clear-test",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("do the thing")},
})
require.NoError(t, err)
seedLastTurnSummary(ctx, t, db, chat, "previous summary")
server.Start()
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
fromDB, dbErr := db.GetChatByID(ctx, chat.ID)
return dbErr == nil &&
fromDB.Status == database.ChatStatusError &&
mockPush.dispatchCount.Load() >= 1
}, testutil.IntervalFast)
chatd.WaitUntilIdleForTest(server)
fromDB, err := db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.False(t, fromDB.LastTurnSummary.Valid,
"errored chats should clear cached turn summaries")
msg := mockPush.getLastMessage()
require.NotEqual(t, "Agent encountered an error.", msg.Body)
require.Contains(t, msg.Body, "OpenAI returned an unexpected error")
}
func TestComputerUseSubagentToolsAndModel(t *testing.T) {
t.Parallel()
@@ -6531,8 +6765,9 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) {
// computer use child chat). We use a raw HTTP handler because
// the chattest AnthropicRequest struct does not capture tools.
type anthropicCall struct {
Model string
Tools []string
Model string
Tools []string
Stream bool
}
var anthropicMu sync.Mutex
var anthropicCalls []anthropicCall
@@ -6563,8 +6798,9 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) {
}
anthropicMu.Lock()
anthropicCalls = append(anthropicCalls, anthropicCall{
Model: req.Model,
Tools: names,
Model: req.Model,
Tools: names,
Stream: req.Stream,
})
anthropicMu.Unlock()
@@ -6737,11 +6973,15 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) {
got.Status != database.ChatStatusError {
return false
}
// Ensure the Anthropic mock received at least one call.
// Ensure the Anthropic mock received the child streaming call.
anthropicMu.Lock()
n := len(anthropicCalls)
anthropicMu.Unlock()
return n >= 1
defer anthropicMu.Unlock()
for _, call := range anthropicCalls {
if call.Stream {
return true
}
}
return false
}, testutil.WaitLong, testutil.IntervalFast)
anthropicMu.Lock()
@@ -6751,8 +6991,18 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) {
require.NotEmpty(t, calls,
"expected at least one Anthropic LLM call")
childModel := calls[0].Model
childTools := calls[0].Tools
var childCall anthropicCall
for _, call := range calls {
if call.Stream {
childCall = call
break
}
}
require.True(t, childCall.Stream,
"expected at least one streaming Anthropic child LLM call")
childModel := childCall.Model
childTools := childCall.Tools
// 1. Verify the model is the computer use model.
require.Equal(t, computerUseModelName, childModel,
+1 -1
View File
@@ -248,7 +248,7 @@ func (p *Server) maybeGenerateChatTitle(
return
}
_, err = p.db.UpdateChatByID(ctx, database.UpdateChatByIDParams{
_, err = p.db.UpdateChatTitleByID(ctx, database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: title,
})
+94
View File
@@ -11,9 +11,14 @@ import (
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func Test_extractManualTitleTurns(t *testing.T) {
@@ -354,6 +359,95 @@ func Test_renderManualTitlePrompt(t *testing.T) {
}
}
func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
owner := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: owner.ID,
OrganizationID: org.ID,
})
dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai",
DisplayName: "OpenAI",
APIKey: "test-key",
Enabled: true,
CentralApiKeyEnabled: true,
})
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Provider: "openai",
Model: "test-model",
})
userPrompt := "summarize failed workspace build logs"
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: owner.ID,
LastModelConfigID: modelConfig.ID,
Title: fallbackChatTitle(userPrompt),
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
})
expectedUpdatedAt := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC)
chat, err := db.UpdateChatStatusPreserveUpdatedAt(ctx, database.UpdateChatStatusPreserveUpdatedAtParams{
ID: chat.ID,
Status: chat.Status,
UpdatedAt: expectedUpdatedAt,
})
require.NoError(t, err)
const wantTitle = "Failed workspace logs"
model := &chattest.FakeModel{
GenerateObjectFn: func(_ context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
require.Equal(t, "propose_title", call.SchemaName)
return &fantasy.ObjectResponse{
Object: map[string]any{"title": wantTitle},
}, nil
},
}
message := mustChatMessage(
t,
database.ChatMessageRoleUser,
database.ChatMessageVisibilityBoth,
codersdk.ChatMessageText(userPrompt),
)
message.ID = 1
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
generated := &generatedChatTitle{}
server := &Server{db: db}
server.maybeGenerateChatTitle(
ctx,
chat,
[]database.ChatMessage{message},
"openai",
"test-model",
model,
chatprovider.ProviderAPIKeys{},
generated,
logger,
nil,
)
fetched, err := db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, wantTitle, fetched.Title)
require.True(t, fetched.UpdatedAt.Equal(expectedUpdatedAt),
"updated_at = %s, want same instant as %s",
fetched.UpdatedAt,
expectedUpdatedAt,
)
gotTitle, ok := generated.Load()
require.True(t, ok)
require.Equal(t, wantTitle, gotTitle)
}
func Test_titleGenerationPrompt_UsesSlimRules(t *testing.T) {
t.Parallel()
+5 -5
View File
@@ -51,7 +51,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideUnset(t *testing.T) {
}
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil)
db.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{
db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: wantTitle,
}).Return(chatWithTitle(chat, wantTitle), nil)
@@ -98,7 +98,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideUnset(t *testing.T) {
}
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil)
db.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{
db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: wantTitle,
}).Return(chatWithTitle(chat, wantTitle), nil)
@@ -146,7 +146,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideReadDBError(t *testing.T)
}
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", sql.ErrConnDone)
db.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{
db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: wantTitle,
}).Return(chatWithTitle(chat, wantTitle), nil)
@@ -193,7 +193,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideMalformedFallsThrough(t *
}
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("not-a-uuid", nil)
db.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{
db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: wantTitle,
}).Return(chatWithTitle(chat, wantTitle), nil)
@@ -247,7 +247,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUsable(t *testing.T) {
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil)
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{Provider: "openai"}}, nil)
db.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{
db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: wantTitle,
}).Return(chatWithTitle(chat, wantTitle), nil)
@@ -0,0 +1,194 @@
package chatd
import (
"context"
"database/sql"
"encoding/json"
"sync/atomic"
"testing"
"time"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestUpdateLastTurnSummaryRejectsStaleWrites(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
owner := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: owner.ID,
OrganizationID: org.ID,
})
_, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{
Provider: "openai",
DisplayName: "OpenAI",
APIKey: "test-key",
Enabled: true,
CentralApiKeyEnabled: true,
})
require.NoError(t, err)
modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
Provider: "openai",
Model: "test-model",
DisplayName: "Test Model",
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
Enabled: true,
IsDefault: true,
ContextLimit: 128000,
CompressionThreshold: 80,
Options: json.RawMessage(`{}`),
})
require.NoError(t, err)
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "summary-chat",
})
require.NoError(t, err)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
server := &Server{db: db}
server.updateLastTurnSummary(ctx, chat, chat.UpdatedAt, "fresh summary", logger)
fetched, err := db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, sql.NullString{String: "fresh summary", Valid: true}, fetched.LastTurnSummary)
advancedUpdatedAt := chat.UpdatedAt.Add(time.Second)
_, err = db.UpdateChatStatusPreserveUpdatedAt(ctx, database.UpdateChatStatusPreserveUpdatedAtParams{
ID: chat.ID,
Status: database.ChatStatusRunning,
UpdatedAt: advancedUpdatedAt,
})
require.NoError(t, err)
server.updateLastTurnSummary(context.WithoutCancel(ctx), chat, chat.UpdatedAt, "stale summary", logger)
fetched, err = db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, sql.NullString{String: "fresh summary", Valid: true}, fetched.LastTurnSummary)
require.Equal(t, advancedUpdatedAt, fetched.UpdatedAt)
}
func TestPendingChatPersistsSummaryButSkipsWebPush(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
owner := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: owner.ID,
OrganizationID: org.ID,
})
_, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{
Provider: "openai",
DisplayName: "OpenAI",
APIKey: "test-key",
Enabled: true,
CentralApiKeyEnabled: true,
})
require.NoError(t, err)
modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
Provider: "openai",
Model: "test-model",
DisplayName: "Test Model",
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
Enabled: true,
IsDefault: true,
ContextLimit: 128000,
CompressionThreshold: 80,
Options: json.RawMessage(`{}`),
})
require.NoError(t, err)
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusPending,
ClientType: database.ChatClientTypeUi,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "summary-pending-chat",
})
require.NoError(t, err)
const summary = "Finished the queued turn."
model := &chattest.FakeModel{
ProviderName: "openai",
ModelName: "test-model",
GenerateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
return &fantasy.Response{
Content: fantasy.ResponseContent{
fantasy.TextContent{Text: summary},
},
}, nil
},
}
dispatcher := &recordingWebpushDispatcher{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
server := &Server{db: db, webpushDispatcher: dispatcher}
server.maybeFinalizeTurnSummaryAndPush(
context.WithoutCancel(ctx),
chat,
database.ChatStatusPending,
"",
runChatResult{
FinalAssistantText: "I finished the queued turn.",
PushSummaryModel: model,
FallbackProvider: model.Provider(),
FallbackModel: model.Model(),
},
logger,
)
server.drainInflight()
fetched, err := db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, sql.NullString{String: summary, Valid: true}, fetched.LastTurnSummary)
require.Equal(t, int32(0), dispatcher.dispatchCount.Load())
}
type recordingWebpushDispatcher struct {
dispatchCount atomic.Int32
}
func (d *recordingWebpushDispatcher) Dispatch(
_ context.Context,
_ uuid.UUID,
_ codersdk.WebpushMessage,
) error {
d.dispatchCount.Add(1)
return nil
}
func (*recordingWebpushDispatcher) Test(_ context.Context, _ codersdk.WebpushSubscription) error {
return nil
}
func (*recordingWebpushDispatcher) PublicKey() string {
return "test-vapid-public-key"
}