Files
coder/coderd/x/chatd/summarygen_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

331 lines
11 KiB
Go

package chatd
import (
"database/sql"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/codersdk"
)
func summaryTestMessage(
t *testing.T,
id int64,
role database.ChatMessageRole,
visibility database.ChatMessageVisibility,
parts []codersdk.ChatMessagePart,
compressed bool,
createdAt time.Time,
) database.ChatMessage {
t.Helper()
content, err := chatprompt.MarshalParts(parts)
require.NoError(t, err)
return database.ChatMessage{
ID: id,
Role: role,
Visibility: visibility,
Content: content,
ContentVersion: chatprompt.CurrentContentVersion,
Compressed: compressed,
CreatedAt: createdAt,
}
}
func summaryTextMessage(
t *testing.T,
id int64,
role database.ChatMessageRole,
visibility database.ChatMessageVisibility,
text string,
compressed bool,
createdAt time.Time,
) database.ChatMessage {
t.Helper()
return summaryTestMessage(t, id, role, visibility,
[]codersdk.ChatMessagePart{codersdk.ChatMessageText(text)},
compressed, createdAt)
}
func TestRenderChatSummaryTranscript(t *testing.T) {
t.Parallel()
base := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC)
messages := []database.ChatMessage{
summaryTextMessage(t, 1, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "you are a helpful agent", false, base),
// Compaction summary (model-only but compressed) is kept.
summaryTextMessage(t, 2, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "earlier work compaction summary", true, base.Add(time.Minute)),
// Injected context (model-only, not compressed) is skipped as noise.
summaryTextMessage(t, 3, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "AGENTS.md injected context", false, base.Add(2*time.Minute)),
summaryTextMessage(t, 4, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "fix the bug in foo.go", false, base.Add(3*time.Minute)),
summaryTextMessage(t, 8, database.ChatMessageRoleUser, database.ChatMessageVisibilityUser, "and please keep it simple", false, base.Add(3*time.Minute+30*time.Second)),
summaryTestMessage(t, 5, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth,
[]codersdk.ChatMessagePart{codersdk.ChatMessageToolCall("call-1", "bash", []byte(`{"cmd":"go test"}`))},
false, base.Add(4*time.Minute)),
summaryTextMessage(t, 6, database.ChatMessageRoleTool, database.ChatMessageVisibilityBoth, "tests passed", false, base.Add(5*time.Minute)),
summaryTextMessage(t, 7, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, "fixed the bug and added a test", false, base.Add(6*time.Minute)),
}
transcript := renderChatSummaryTranscript(messages)
require.Contains(t, transcript, "earlier work compaction summary")
require.Contains(t, transcript, "[user]: fix the bug in foo.go")
require.Contains(t, transcript, "[user]: and please keep it simple")
require.Contains(t, transcript, "[assistant]: fixed the bug and added a test")
// System prompt, injected context, tool-call, and tool result are excluded.
require.NotContains(t, transcript, "you are a helpful agent")
require.NotContains(t, transcript, "AGENTS.md injected context")
require.NotContains(t, transcript, "tests passed")
require.NotContains(t, transcript, "go test")
}
func TestBoundTranscriptHeadTail(t *testing.T) {
t.Parallel()
t.Run("UnderBudgetReturnsAll", func(t *testing.T) {
t.Parallel()
lines := []string{"a", "b", "c"}
require.Equal(t, "a\nb\nc", boundTranscriptHeadTail(lines, 1000))
})
t.Run("OverBudgetKeepsHeadAndTail", func(t *testing.T) {
t.Parallel()
lines := []string{
"HEAD-FIRST " + strings.Repeat("x", 40),
strings.Repeat("m", 200),
strings.Repeat("n", 200),
strings.Repeat("o", 200),
"TAIL-LAST " + strings.Repeat("y", 40),
}
out := boundTranscriptHeadTail(lines, 160)
require.Contains(t, out, "HEAD-FIRST")
require.Contains(t, out, "TAIL-LAST")
require.Contains(t, out, "[... earlier turns omitted ...]")
})
}
func TestShouldGenerateChatSummary(t *testing.T) {
t.Parallel()
base := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC)
userMsg := func(id int64, at time.Time) database.ChatMessage {
return database.ChatMessage{
ID: id,
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
CreatedAt: at,
}
}
assistantMsg := func(id int64, at time.Time) database.ChatMessage {
return database.ChatMessage{
ID: id,
Role: database.ChatMessageRoleAssistant,
Visibility: database.ChatMessageVisibilityBoth,
CreatedAt: at,
}
}
t.Run("FirstSummaryAtFirstTurn", func(t *testing.T) {
t.Parallel()
chat := database.Chat{}
msgs := []database.ChatMessage{
userMsg(1, base),
assistantMsg(2, base.Add(time.Minute)),
}
require.True(t, shouldGenerateChatSummary(chat, msgs))
})
t.Run("FirstSummarySkippedWithNoTurns", func(t *testing.T) {
t.Parallel()
chat := database.Chat{}
require.False(t, shouldGenerateChatSummary(chat, nil))
})
t.Run("MultiStepTurnDoesNotInflateCount", func(t *testing.T) {
t.Parallel()
marker := base
chat := database.Chat{
Summary: sql.NullString{String: "existing", Valid: true},
SummaryGeneratedAt: sql.NullTime{Time: marker, Valid: true},
}
// One user turn plus many assistant steps stays below the threshold.
msgs := []database.ChatMessage{
userMsg(1, marker.Add(time.Minute)),
assistantMsg(2, marker.Add(2*time.Minute)),
assistantMsg(3, marker.Add(3*time.Minute)),
assistantMsg(4, marker.Add(4*time.Minute)),
}
require.False(t, shouldGenerateChatSummary(chat, msgs))
})
t.Run("ModelOnlyUserMessagesAreNotTurns", func(t *testing.T) {
t.Parallel()
marker := base
chat := database.Chat{
Summary: sql.NullString{String: "existing", Valid: true},
SummaryGeneratedAt: sql.NullTime{Time: marker, Valid: true},
}
modelOnlyUserMsg := func(id int64, at time.Time) database.ChatMessage {
return database.ChatMessage{
ID: id,
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityModel,
CreatedAt: at,
}
}
// The model-only user message must not count as a turn, else these
// three messages would trip the threshold of 3.
msgs := []database.ChatMessage{
userMsg(1, marker.Add(time.Minute)),
modelOnlyUserMsg(2, marker.Add(2*time.Minute)),
userMsg(3, marker.Add(3*time.Minute)),
}
require.False(t, shouldGenerateChatSummary(chat, msgs))
})
t.Run("RefreshAfterThresholdTurns", func(t *testing.T) {
t.Parallel()
marker := base
chat := database.Chat{
Summary: sql.NullString{String: "existing", Valid: true},
SummaryGeneratedAt: sql.NullTime{Time: marker, Valid: true},
}
msgs := []database.ChatMessage{
// Pre-marker turn is not counted.
userMsg(1, marker.Add(-time.Minute)),
userMsg(2, marker.Add(time.Minute)),
userMsg(3, marker.Add(2*time.Minute)),
userMsg(4, marker.Add(3*time.Minute)),
}
require.True(t, shouldGenerateChatSummary(chat, msgs))
})
t.Run("PreMarkerTurnsAreNotCounted", func(t *testing.T) {
t.Parallel()
marker := base
chat := database.Chat{
Summary: sql.NullString{String: "existing", Valid: true},
SummaryGeneratedAt: sql.NullTime{Time: marker, Valid: true},
}
// The pre-marker turn would tip the total to the threshold; this stays
// false only because countCompletedTurnsSince excludes pre-marker turns.
msgs := []database.ChatMessage{
userMsg(1, marker.Add(-time.Minute)),
userMsg(2, marker.Add(time.Minute)),
userMsg(3, marker.Add(2*time.Minute)),
}
require.False(t, shouldGenerateChatSummary(chat, msgs))
})
}
func TestValidateGeneratedChatSummary(t *testing.T) {
t.Parallel()
require.Error(t, validateGeneratedChatSummary(""))
require.Error(t, validateGeneratedChatSummary(strings.Repeat("a", summaryMaxRunes+1)))
require.Error(t, validateGeneratedChatSummary("One. Two. Three. Four. Five. Six. Seven."))
require.NoError(t, validateGeneratedChatSummary("Implemented the summary feature. Added tests."))
}
func TestCountSentenceTerminators(t *testing.T) {
t.Parallel()
// Periods inside dotted identifiers (pkg.cmd.server) are not boundaries.
require.Equal(t, 2, countSentenceTerminators("Fixed pkg.cmd.server in file.go. Added a test."))
require.Equal(t, 3, countSentenceTerminators("One. Two! Three?"))
require.Equal(t, 0, countSentenceTerminators("auth.rbac.Policy"))
// Dotted identifiers must not push a valid summary over the sentence cap.
require.NoError(t, validateGeneratedChatSummary(
"Refactored pkg.cmd.server and auth.rbac.Policy in main.go and util.go. Added coverage in foo_test.go.",
))
}
func TestSubagentReportSummarySnippet(t *testing.T) {
t.Parallel()
tests := []struct {
name string
report string
want string
}{
{
name: "ProseLeadKeepsFirstSentences",
report: "Done. Both fixes are pushed as separate commits. Validation passed. Extra detail here.\n\nMore paragraphs follow.",
want: "Done. Both fixes are pushed as separate commits. Validation passed.",
},
{
name: "SkipsLeadingHeading",
report: "## Summary\n\nFixed the flaky test by pinning the clock.",
want: "Fixed the flaky test by pinning the clock.",
},
{
name: "StripsInlineMarkdown",
report: "Fixed **the race** in `cache.go`; see [the PR](https://example.com) for details.",
want: "Fixed the race in cache.go; see the PR for details.",
},
{
name: "JoinsWrappedLines",
report: "Fixed the race\nin the cache layer.\n\nDetails below.",
want: "Fixed the race in the cache layer.",
},
{
name: "BulletLeadReport",
report: "- Fixed A.\n- Fixed B.\n- Fixed C.\n- Fixed D.",
want: "Fixed A. Fixed B. Fixed C.",
},
{
name: "SkipsLeadingCodeFence",
report: "```\ngo test ./...\n```\n\nAll tests passed.",
want: "All tests passed.",
},
{
name: "FenceEndsParagraph",
report: "Ran the suite:\n```\nok 12 packages\n```\nThen more prose.",
want: "Ran the suite:",
},
{
name: "SkipsTableAndRule",
report: "| a | b |\n|---|---|\n\n---\n\nRolled out the migration.",
want: "Rolled out the migration.",
},
{
name: "PreservesSnakeCaseIdentifiers",
report: "Renamed parent_chat_id to root_chat_id in the query.",
want: "Renamed parent_chat_id to root_chat_id in the query.",
},
{
name: "TruncatesUnterminatedText",
report: strings.Repeat("a", subagentReportSummaryMaxRunes+100),
want: strings.Repeat("a", subagentReportSummaryMaxRunes-1) + "…",
},
{
name: "DropsSentencesPastRuneCap",
report: "Short lead sentence. " +
strings.Repeat("b", subagentReportSummaryMaxRunes) + ".",
want: "Short lead sentence.",
},
{
name: "EmptyReport",
report: " \n\t\n",
want: "",
},
{
name: "OnlyCodeAndHeadings",
report: "## Log\n```\nstack trace\n```\n",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, subagentReportSummarySnippet(tt.report))
})
}
}