mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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>
331 lines
11 KiB
Go
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))
|
|
})
|
|
}
|
|
}
|