diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 7d45a4efb2..be71ec44e1 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -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",
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index b6d9fcfce4..9f900a3435 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -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",
diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go
index 325471f805..8e1ed20330 100644
--- a/coderd/database/db2sdk/db2sdk.go
+++ b/coderd/database/db2sdk/db2sdk.go
@@ -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)
}
diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go
index 7a0fa09483..ada8877a18 100644
--- a/coderd/database/db2sdk/db2sdk_test.go
+++ b/coderd/database/db2sdk/db2sdk_test.go
@@ -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,
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 9badded7e0..d5e56e504e 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -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 {
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index 795a0e6641..e58d01264c 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -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{
diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go
index 125e86b2a4..d9a9963f9e 100644
--- a/coderd/database/dbmetrics/querymetrics.go
+++ b/coderd/database/dbmetrics/querymetrics.go
@@ -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)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index bfb29d8559..ead72d0945 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -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()
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 491bbee16b..e7d192a0ca 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -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)))
);
diff --git a/coderd/database/migrations/000488_chat_last_turn_summary.down.sql b/coderd/database/migrations/000488_chat_last_turn_summary.down.sql
new file mode 100644
index 0000000000..e74c61d51d
--- /dev/null
+++ b/coderd/database/migrations/000488_chat_last_turn_summary.down.sql
@@ -0,0 +1 @@
+ALTER TABLE chats DROP COLUMN last_turn_summary;
diff --git a/coderd/database/migrations/000488_chat_last_turn_summary.up.sql b/coderd/database/migrations/000488_chat_last_turn_summary.up.sql
new file mode 100644
index 0000000000..cb2b9a5bf6
--- /dev/null
+++ b/coderd/database/migrations/000488_chat_last_turn_summary.up.sql
@@ -0,0 +1 @@
+ALTER TABLE chats ADD COLUMN last_turn_summary TEXT;
diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go
index c1d89c8a12..78331e338b 100644
--- a/coderd/database/modelqueries.go
+++ b/coderd/database/modelqueries.go
@@ -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
}
diff --git a/coderd/database/models.go b/coderd/database/models.go
index 65e6d5a142..a9dc787afb 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -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 {
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 795c9a7af1..935453f2fc 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -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)
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index 30ae724ff7..5cf790a4c5 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -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()
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index c18e34bb19..03fa2904a9 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -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
}
diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql
index 4f3e6935ad..16c3b45da9 100644
--- a/coderd/database/queries/chats.sql
+++ b/coderd/database/queries/chats.sql
@@ -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
diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go
index ff551bb76f..49df083dd4 100644
--- a/coderd/x/chatd/chatd.go
+++ b/coderd/x/chatd/chatd.go
@@ -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,
diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go
index bd1d33f788..093d2da517 100644
--- a/coderd/x/chatd/chatd_internal_test.go
+++ b/coderd/x/chatd/chatd_internal_test.go
@@ -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
diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go
index 0283a95030..d160bbe8d1 100644
--- a/coderd/x/chatd/chatd_test.go
+++ b/coderd/x/chatd/chatd_test.go
@@ -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,
diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go
index 683be44dbe..e76545527e 100644
--- a/coderd/x/chatd/quickgen.go
+++ b/coderd/x/chatd/quickgen.go
@@ -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,
})
diff --git a/coderd/x/chatd/quickgen_test.go b/coderd/x/chatd/quickgen_test.go
index 5d0b47b6ad..fb87a8a73b 100644
--- a/coderd/x/chatd/quickgen_test.go
+++ b/coderd/x/chatd/quickgen_test.go
@@ -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()
diff --git a/coderd/x/chatd/title_override_test.go b/coderd/x/chatd/title_override_test.go
index 145f3c91d1..4fc0b2badc 100644
--- a/coderd/x/chatd/title_override_test.go
+++ b/coderd/x/chatd/title_override_test.go
@@ -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)
diff --git a/coderd/x/chatd/turn_summary_internal_test.go b/coderd/x/chatd/turn_summary_internal_test.go
new file mode 100644
index 0000000000..fab0ed3e7f
--- /dev/null
+++ b/coderd/x/chatd/turn_summary_internal_test.go
@@ -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"
+}
diff --git a/codersdk/chats.go b/codersdk/chats.go
index 43d9e57b77..a0eab2868c 100644
--- a/codersdk/chats.go
+++ b/codersdk/chats.go
@@ -110,6 +110,7 @@ type Chat struct {
Status ChatStatus `json:"status"`
PlanMode ChatPlanMode `json:"plan_mode,omitempty"`
LastError *ChatError `json:"last_error,omitempty"`
+ LastTurnSummary *string `json:"last_turn_summary"`
DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
@@ -1594,6 +1595,7 @@ type ChatWatchEventKind string
const (
ChatWatchEventKindStatusChange ChatWatchEventKind = "status_change"
+ ChatWatchEventKindSummaryChange ChatWatchEventKind = "summary_change"
ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change"
ChatWatchEventKindCreated ChatWatchEventKind = "created"
ChatWatchEventKindDeleted ChatWatchEventKind = "deleted"
@@ -1602,8 +1604,8 @@ const (
)
// ChatWatchEvent represents an event from the global chat watch stream.
-// It delivers lifecycle events (created, status change, title change)
-// for all of the authenticated user's chats. When Kind is
+// It delivers lifecycle events (created, status change, summary change,
+// title change) for all of the authenticated user's chats. When Kind is
// ActionRequired, ToolCalls contains the pending dynamic tool
// invocations the client must execute and submit back.
type ChatWatchEvent struct {
diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md
index 60aa73ba70..1f028ff055 100644
--- a/docs/admin/security/audit-logs.md
+++ b/docs/admin/security/audit-logs.md
@@ -20,7 +20,7 @@ We track the following resources:
| AuditOAuthConvertState
|
| Field | Tracked |
| | created_at | true |
| expires_at | true |
| from_login_type | true |
| to_login_type | true |
| user_id | true |
|
| Group
create, write, delete | | Field | Tracked |
| | avatar_url | true |
| chat_spend_limit_micros | true |
| display_name | true |
| id | true |
| members | true |
| name | true |
| organization_id | false |
| quota_allowance | true |
| source | false |
|
| AuditableOrganizationMember
| | Field | Tracked |
| | created_at | true |
| organization_id | false |
| roles | true |
| updated_at | true |
| user_id | true |
| username | true |
|
-| Chat
create, write | | Field | Tracked |
| | agent_id | false |
| archived | true |
| build_id | false |
| client_type | false |
| created_at | false |
| dynamic_tools | false |
| heartbeat_at | false |
| id | true |
| labels | true |
| last_error | false |
| last_injected_context | false |
| last_model_config_id | false |
| last_read_message_id | false |
| mcp_server_ids | true |
| mode | true |
| organization_id | false |
| owner_id | true |
| parent_chat_id | false |
| pin_order | true |
| plan_mode | false |
| root_chat_id | false |
| started_at | false |
| status | false |
| title | true |
| updated_at | false |
| worker_id | false |
| workspace_id | true |
|
+| Chat
create, write | | Field | Tracked |
| | agent_id | false |
| archived | true |
| build_id | false |
| client_type | false |
| created_at | false |
| dynamic_tools | false |
| heartbeat_at | false |
| id | true |
| labels | true |
| last_error | false |
| last_injected_context | false |
| last_model_config_id | false |
| last_read_message_id | false |
| last_turn_summary | false |
| mcp_server_ids | true |
| mode | true |
| organization_id | false |
| owner_id | true |
| parent_chat_id | false |
| pin_order | true |
| plan_mode | false |
| root_chat_id | false |
| started_at | false |
| status | false |
| title | true |
| updated_at | false |
| worker_id | false |
| workspace_id | true |
|
| CustomRole
| | Field | Tracked |
| | created_at | false |
| display_name | true |
| id | false |
| is_system | false |
| member_permissions | true |
| name | true |
| org_permissions | true |
| organization_id | false |
| site_permissions | true |
| updated_at | false |
| user_permissions | true |
|
| GitSSHKey
create | | Field | Tracked |
| | created_at | false |
| private_key | true |
| public_key | true |
| updated_at | false |
| user_id | true |
|
| GroupSyncSettings
| | Field | Tracked |
| | auto_create_missing_groups | true |
| field | true |
| legacy_group_name_mapping | false |
| mapping | true |
| regex_filter | true |
|
diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md
index c352e2e4c7..296142fac6 100644
--- a/docs/reference/api/chats.md
+++ b/docs/reference/api/chats.md
@@ -140,6 +140,7 @@ Experimental: this endpoint is subject to change.
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -261,6 +262,7 @@ Status Code **200**
| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | |
| `»» url` | string | false | | |
| `» last_model_config_id` | string(uuid) | false | | |
+| `» last_turn_summary` | string | false | | |
| `» mcp_server_ids` | array | false | | |
| `» organization_id` | string(uuid) | false | | |
| `» owner_id` | string(uuid) | false | | |
@@ -468,6 +470,7 @@ Experimental: this endpoint is subject to change.
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -591,6 +594,7 @@ Experimental: this endpoint is subject to change.
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -865,6 +869,7 @@ Experimental: this endpoint is subject to change.
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -1042,6 +1047,7 @@ Experimental: this endpoint is subject to change.
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -1165,6 +1171,7 @@ Experimental: this endpoint is subject to change.
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -1423,6 +1430,7 @@ Experimental: this endpoint is subject to change.
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -1546,6 +1554,7 @@ Experimental: this endpoint is subject to change.
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -2583,6 +2592,7 @@ Experimental: this endpoint is subject to change.
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -2706,6 +2716,7 @@ Experimental: this endpoint is subject to change.
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 57c50a8d7b..99ca97beff 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -2197,6 +2197,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -2320,6 +2321,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -2358,6 +2360,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `last_error` | [codersdk.ChatError](#codersdkchaterror) | false | | |
| `last_injected_context` | array of [codersdk.ChatMessagePart](#codersdkchatmessagepart) | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. |
| `last_model_config_id` | string | false | | |
+| `last_turn_summary` | string | false | | |
| `mcp_server_ids` | array of string | false | | |
| `organization_id` | string | false | | |
| `owner_id` | string | false | | |
@@ -3731,6 +3734,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}
],
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
+ "last_turn_summary": "string",
"mcp_server_ids": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
@@ -3777,9 +3781,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
#### Enumerated Values
-| Value(s) |
-|------------------------------------------------------------------------------------------------|
-| `action_required`, `created`, `deleted`, `diff_status_change`, `status_change`, `title_change` |
+| Value(s) |
+|------------------------------------------------------------------------------------------------------------------|
+| `action_required`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` |
## codersdk.ConnectionLatency
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index 98f26b9100..1ccf6f4628 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -399,6 +399,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"last_model_config_id": ActionIgnore, // Churns every message.
"archived": ActionTrack,
"last_error": ActionIgnore, // Internal.
+ "last_turn_summary": ActionIgnore, // Internal cached display text.
"mode": ActionTrack,
"mcp_server_ids": ActionTrack,
"labels": ActionTrack,
diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts
index 5ba7549c31..7938e80742 100644
--- a/site/src/api/queries/chats.test.ts
+++ b/site/src/api/queries/chats.test.ts
@@ -112,6 +112,7 @@ const makeChat = (
pin_order: 0,
has_unread: false,
client_type: "ui",
+ last_turn_summary: null,
children: [],
...overrides,
});
@@ -2027,6 +2028,57 @@ describe("mergeWatchedChatSummary", () => {
).toBe("22222222-2222-4222-8222-222222222222");
});
+ it("merges last_turn_summary when watched updated_at equals cached updated_at", () => {
+ const cachedChat = makeChat("chat-1", {
+ last_turn_summary: "Previous summary",
+ updated_at: "2025-01-01T00:00:00.000Z",
+ });
+ const watchedChat = makeChat("chat-1", {
+ last_turn_summary: "Updated summary",
+ updated_at: "2025-01-01T00:00:00.000Z",
+ });
+
+ expect(
+ mergeWatchedChatSummary(cachedChat, watchedChat, {
+ eventKind: "summary_change",
+ }).last_turn_summary,
+ ).toBe("Updated summary");
+ });
+
+ it("applies summary_change even when event updated_at is older", () => {
+ const cachedChat = makeChat("chat-1", {
+ last_turn_summary: null,
+ updated_at: "2025-01-01T00:05:00.000Z",
+ });
+ const watchedChat = makeChat("chat-1", {
+ last_turn_summary: "Fixed the issue",
+ updated_at: "2025-01-01T00:00:00.000Z",
+ });
+
+ expect(
+ mergeWatchedChatSummary(cachedChat, watchedChat, {
+ eventKind: "summary_change",
+ }).last_turn_summary,
+ ).toBe("Fixed the issue");
+ });
+
+ it("clears last_turn_summary on summary updates with matching updated_at", () => {
+ const cachedChat = makeChat("chat-1", {
+ last_turn_summary: "Previous summary",
+ updated_at: "2025-01-01T00:00:00.000Z",
+ });
+ const watchedChat = makeChat("chat-1", {
+ last_turn_summary: null,
+ updated_at: "2025-01-01T00:00:00.000Z",
+ });
+
+ expect(
+ mergeWatchedChatSummary(cachedChat, watchedChat, {
+ eventKind: "summary_change",
+ }).last_turn_summary,
+ ).toBeNull();
+ });
+
it("compares updated_at values as instants instead of strings", () => {
const cachedChat = makeChat("chat-1", {
status: "pending",
@@ -2216,6 +2268,25 @@ describe("mergeWatchedChatSummary", () => {
).toBe(true);
});
+ it("preserves has_unread for summary changes on inactive chats", () => {
+ const cachedChat = makeChat("chat-1", {
+ has_unread: false,
+ last_turn_summary: null,
+ updated_at: "2025-01-01T00:00:00.000Z",
+ });
+ const watchedChat = makeChat("chat-1", {
+ last_turn_summary: "Updated summary",
+ updated_at: "2025-01-01T00:05:00.000Z",
+ });
+
+ expect(
+ mergeWatchedChatSummary(cachedChat, watchedChat, {
+ eventKind: "summary_change",
+ activeChatId: "chat-2",
+ }).has_unread,
+ ).toBe(false);
+ });
+
it("preserves has_unread for the active chat", () => {
const cachedChat = makeChat("chat-1", {
has_unread: false,
diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts
index 670cf68a16..89964a9d58 100644
--- a/site/src/api/queries/chats.ts
+++ b/site/src/api/queries/chats.ts
@@ -260,6 +260,7 @@ export const mergeWatchedChatSummary = (
): TypesGen.Chat => {
const isTitleEvent = eventKind === "title_change";
const isStatusEvent = eventKind === "status_change";
+ const isSummaryEvent = eventKind === "summary_change";
const isDiffStatusEvent = eventKind === "diff_status_change";
const updatedAtComparison = compareUpdatedAtInstants(
cachedChat.updated_at,
@@ -286,6 +287,10 @@ export const mergeWatchedChatSummary = (
const nextLastModelConfigId = isFreshEnough
? watchedChat.last_model_config_id
: cachedChat.last_model_config_id;
+ const nextLastTurnSummary =
+ isFreshEnough || isSummaryEvent
+ ? watchedChat.last_turn_summary
+ : cachedChat.last_turn_summary;
const nextHasUnread =
isFreshEnough && isStatusEvent && watchedChat.id !== activeChatId
? true
@@ -303,6 +308,7 @@ export const mergeWatchedChatSummary = (
nextWorkspaceId === cachedChat.workspace_id &&
nextBuildId === cachedChat.build_id &&
nextLastModelConfigId === cachedChat.last_model_config_id &&
+ nextLastTurnSummary === cachedChat.last_turn_summary &&
nextHasUnread === cachedChat.has_unread &&
nextUpdatedAt === cachedChat.updated_at
) {
@@ -317,6 +323,7 @@ export const mergeWatchedChatSummary = (
workspace_id: nextWorkspaceId,
build_id: nextBuildId,
last_model_config_id: nextLastModelConfigId,
+ last_turn_summary: nextLastTurnSummary,
has_unread: nextHasUnread,
updated_at: nextUpdatedAt,
};
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 7e0667e00c..bcbfa35ddb 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -1287,6 +1287,7 @@ export interface Chat {
readonly status: ChatStatus;
readonly plan_mode?: ChatPlanMode;
readonly last_error?: ChatError;
+ readonly last_turn_summary: string | null;
readonly diff_status?: ChatDiffStatus;
readonly created_at: string;
readonly updated_at: string;
@@ -2712,8 +2713,8 @@ export interface ChatUsageLimitStatus {
// From codersdk/chats.go
/**
* ChatWatchEvent represents an event from the global chat watch stream.
- * It delivers lifecycle events (created, status change, title change)
- * for all of the authenticated user's chats. When Kind is
+ * It delivers lifecycle events (created, status change, summary change,
+ * title change) for all of the authenticated user's chats. When Kind is
* ActionRequired, ToolCalls contains the pending dynamic tool
* invocations the client must execute and submit back.
*/
@@ -2730,6 +2731,7 @@ export type ChatWatchEventKind =
| "deleted"
| "diff_status_change"
| "status_change"
+ | "summary_change"
| "title_change";
export const ChatWatchEventKinds: ChatWatchEventKind[] = [
@@ -2738,6 +2740,7 @@ export const ChatWatchEventKinds: ChatWatchEventKind[] = [
"deleted",
"diff_status_change",
"status_change",
+ "summary_change",
"title_change",
];
diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx
index 7dc7d12d79..f246b0d132 100644
--- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx
+++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx
@@ -137,6 +137,7 @@ const baseChatFields = {
pin_order: 0,
has_unread: false,
client_type: "ui",
+ last_turn_summary: null,
children: [],
} as const;
diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx
index bc8021b180..5efc885d6b 100644
--- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx
+++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx
@@ -63,6 +63,7 @@ const buildChat = (overrides: Partial = {}): TypesGen.Chat => ({
pin_order: 0,
has_unread: false,
client_type: "ui",
+ last_turn_summary: null,
children: [],
...overrides,
});
diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx
index f68f0f64b6..1596b69720 100644
--- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx
+++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx
@@ -146,6 +146,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({
pin_order: 0,
has_unread: false,
client_type: "ui",
+ last_turn_summary: null,
children: [],
...overrides,
});
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx
index baf68d6c23..65f8a1d452 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx
@@ -218,6 +218,7 @@ const makeChat = (chatID: string): TypesGen.Chat => ({
pin_order: 0,
has_unread: false,
client_type: "ui",
+ last_turn_summary: null,
children: [],
});
diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx
index 38dc30283d..c93ca7c9bb 100644
--- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx
@@ -65,6 +65,7 @@ export const WithParentChat: Story = {
labels: {},
title: "Set up CI/CD pipeline",
status: "completed",
+ last_turn_summary: null,
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
archived: false,
diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx
index cef02734a1..53ace0e7b2 100644
--- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx
+++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx
@@ -69,6 +69,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({
pin_order: 0,
has_unread: false,
client_type: "ui",
+ last_turn_summary: null,
children: [],
...overrides,
});
@@ -124,6 +125,53 @@ const meta: Meta = {
export default meta;
type Story = StoryObj;
+export const ChatWithTurnSummary: Story = {
+ args: {
+ chats: [
+ buildChat({
+ id: "chat-turn-summary",
+ title: "Update workspace template",
+ last_turn_summary: "Added Docker and Terraform validation",
+ }),
+ ],
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await expect(
+ canvas.getByText("Added Docker and Terraform validation"),
+ ).toBeInTheDocument();
+ expect(canvas.queryByText("GPT-4o")).not.toBeInTheDocument();
+ },
+};
+
+export const ChatWithTurnSummaryAndError: Story = {
+ args: {
+ chats: [
+ buildChat({
+ id: "chat-turn-summary-error",
+ title: "Fix workspace startup",
+ status: "error",
+ last_error: {
+ message: "Workspace startup failed",
+ retryable: false,
+ },
+ last_turn_summary: "Recreated the workspace image",
+ }),
+ ],
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await expect(
+ canvas.getByText("Workspace startup failed"),
+ ).toBeInTheDocument();
+ expect(
+ canvas.queryByText("Recreated the workspace image"),
+ ).not.toBeInTheDocument();
+ },
+};
+
export const RunningDelegatedChat: Story = {
args: {
chats: [
diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx
index 946dca2748..df079ab99d 100644
--- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx
+++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx
@@ -66,6 +66,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({
pin_order: 0,
has_unread: false,
client_type: "ui",
+ last_turn_summary: null,
mcp_server_ids: [],
labels: {},
children: [],
@@ -522,3 +523,104 @@ describe("AgentsSidebar model display names", () => {
expect(queryByText("Default model")).not.toBeInTheDocument();
});
});
+
+describe("AgentsSidebar subtitles", () => {
+ const modelOptions = [
+ {
+ id: "model-1",
+ provider: "openai",
+ model: "gpt-4o",
+ displayName: "GPT-4o",
+ },
+ ];
+
+ it("shows the last turn summary when present and no error exists", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(
+ screen.getByText("Updated the Terraform template"),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("GPT-4o")).not.toBeInTheDocument();
+ });
+
+ it("shows the error when both error and last turn summary exist", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Workspace startup failed")).toBeInTheDocument();
+ expect(
+ screen.queryByText("Provisioned a workspace"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("falls back to the model name when no last turn summary exists", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("GPT-4o")).toBeInTheDocument();
+ });
+
+ it("falls back to the model name when the last turn summary is blank", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("GPT-4o")).toBeInTheDocument();
+ });
+});
diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx
index 9d38553fa7..eb92d438e9 100644
--- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx
+++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx
@@ -105,6 +105,7 @@ import { cn } from "#/utils/cn";
import { shortRelativeTime } from "#/utils/time";
import { getNormalizedModelRef } from "../../utils/modelOptions";
import { getTimeGroup, TIME_GROUPS } from "../../utils/timeGroups";
+import { asNonEmptyString } from "../ChatConversation/blockUtils";
import type { ModelSelectorOption } from "../ChatElements";
import { asString } from "../ChatElements/runtimeTypeUtils";
import { UsageIndicator } from "../UsageIndicator";
@@ -241,14 +242,6 @@ const getPRIconConfig = (
return { icon: GitPullRequestArrowIcon, className: "text-git-added-bright" };
};
-const asNonEmptyString = (value: unknown): string | undefined => {
- if (typeof value !== "string") {
- return undefined;
- }
- const trimmed = value.trim();
- return trimmed.length > 0 ? trimmed : undefined;
-};
-
const getModelDisplayName = (
lastModelConfigID: Chat["last_model_config_id"] | undefined,
modelConfigs: readonly ChatModelConfig[],
@@ -506,7 +499,8 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => {
chat.status === "error"
? chatErrorReasons[chat.id] || chat.last_error?.message || undefined
: undefined;
- const subtitle = errorReason || modelName;
+ const lastTurnSummary = asNonEmptyString(chat.last_turn_summary);
+ const subtitle = errorReason || lastTurnSummary || modelName;
const diffStatus = getChatDiffStatus(chat);
const baseConfig = getStatusConfig(chat.status);
const prConfig =