Files
coder/coderd/x/chatd/turn_summary_internal_test.go
T
Jaayden HalkoandCursor 6f2011af88 feat: add chat summary tab in the right sidebar and per-chat cost endpoint (#26649)
Stacked on #26657 (the persisted whole-chat summary backend). Base
branch is `chat-summary-62j9`; review/merge that first.

Adds a reusable `ChatSummary` component.

The summary text is the persisted whole-chat summary (`chat.summary`)
introduced by #26657. It is generated asynchronously and may be `null`
until the first summary is produced, in which case the popover renders a
muted empty state. Live updates arrive via that PR's
`chat_summary_change` watch event, which is already merged into the chat
caches.

Cost is served by a new per-chat endpoint, `GET
/api/experimental/chats/{chat}/cost`, which rolls up assistant-message
cost across a chat's root and child (subagent) chats and is authorized
like the other `{chat}` routes (read on the chat, 404 otherwise).

Visual and interaction coverage lives in `ChatSummary.stories.tsx` and
`ChatSummaryPopover.stories.tsx` (including populated-summary,
empty-state, and cost-loading cases).

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:05:05 +01:00

229 lines
7.6 KiB
Go

package chatd
import (
"context"
"database/sql"
"encoding/json"
"sync/atomic"
"testing"
"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/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestUpdateLastTurnSummaryRejectsStaleWrites(t *testing.T) {
t.Parallel()
db, ps := 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,
})
provider := dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai",
DisplayName: "OpenAI",
APIKey: "test-key",
Enabled: true,
})
modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
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)
content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("hello"),
})
require.NoError(t, err)
created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{
OrganizationID: org.ID,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "summary-chat",
ClientType: database.ChatClientTypeUi,
InitialMessages: []chatstate.Message{
{
Role: database.ChatMessageRoleUser,
Content: content,
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true},
},
},
})
require.NoError(t, err)
chat := created.Chat
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
server := &Server{db: db, pubsub: ps}
server.updateLastTurnSummary(ctx, chat, chat.HistoryVersion, "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)
assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("assistant response"),
})
require.NoError(t, err)
machine := chatstate.NewChatMachine(db, ps, chat.ID)
require.NoError(t, machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
_, err := tx.CommitStep(chatstate.CommitStepInput{
Messages: []chatstate.Message{
{
Role: database.ChatMessageRoleAssistant,
Content: assistantContent,
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true},
},
},
})
return err
}))
server.updateLastTurnSummary(context.WithoutCancel(ctx), chat, chat.HistoryVersion, "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)
}
// A successful child chat outcome persists the subagent's final report
// as the chat summary but still skips the turn status label and web
// push, which remain parent-only.
func TestSuccessfulChildChatOutcomeStoresReportSummaryWithoutPush(t *testing.T) {
t.Parallel()
db, ps := 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,
})
provider := dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai",
DisplayName: "OpenAI",
APIKey: "test-key",
Enabled: true,
})
modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
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)
parent, err := db.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "summary-parent-chat",
MCPServerIDs: []uuid.UUID{},
})
require.NoError(t, err)
child, err := db.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: owner.ID,
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
LastModelConfigID: modelCfg.ID,
Title: "summary-child-chat",
MCPServerIDs: []uuid.UUID{},
})
require.NoError(t, err)
const report = "Completed the delegated task."
insertAssistantMessage(t, db, child.ID, modelCfg.ID, report)
// Message inserts bump history_version via trigger; the finalize
// hook receives the post-turn chat, so mirror that here or the
// fenced summary write would be skipped as stale.
child, err = db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
dispatcher := &recordingWebpushDispatcher{}
server := &Server{
ctx: t.Context(),
db: db,
pubsub: ps,
logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
webpushDispatcher: dispatcher,
// deriveFinalTurnRunResult resolves the chat model once the
// child has an assistant message, which requires the cache and
// the clock used to mint the synthetic gateway API key.
clock: quartz.NewReal(),
configCache: newChatConfigCache(context.Background(), db, quartz.NewReal()),
}
require.NoError(t, server.afterGenerationOutcome(ctx, generationOutcome{
Chat: child,
Kind: runnerActionKindFinishTurn,
}))
server.drainInflight()
fetched, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.False(t, fetched.LastTurnSummary.Valid)
require.Equal(t, sql.NullString{String: report, Valid: true}, fetched.Summary)
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"
}