Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ede189b
feat: render chat summaries as a headline plus bullets
jaaydenh Aug 11, 2026
634f890
fix(site/src/pages/AgentsPage/components): address summary panel review
jaaydenh Aug 11, 2026
dff5f06
docs(coderd/x/chatd): correct stale chat summary format comments
jaaydenh Aug 11, 2026
9af0084
fix(site/src/pages/AgentsPage/components): wrap long identifiers in s…
jaaydenh Aug 11, 2026
90f83d6
test(site/src/pages/AgentsPage/components): drop geometry assertion f…
jaaydenh Aug 11, 2026
32cd37b
perf(site/src/pages/AgentsPage/components): settle summary resize mea…
jaaydenh Aug 11, 2026
1b6328d
fix(site/src/pages/AgentsPage/components): reset summary expansion pe…
jaaydenh Aug 11, 2026
0847564
refactor(site/src/pages/AgentsPage/components): drop the chat summary…
jaaydenh Aug 20, 2026
5411d74
Merge remote-tracking branch 'origin/main' into chat-summary-7v0c
jaaydenh Aug 20, 2026
75c4bf9
feat(coderd/x/chatd): allow headline-only chat summaries
jaaydenh Aug 20, 2026
fcad16a
Merge remote-tracking branch 'origin/main' into chat-summary-7v0c
jaaydenh Aug 22, 2026
4a6ff27
refactor(coderd/x/chatd): drop summary checks that normalization make…
jaaydenh Aug 22, 2026
cf5e5af
refactor: trim summary comments and inline list classes
jaaydenh Aug 24, 2026
6df9da2
chore: improvements
jaaydenh Aug 24, 2026
e2ec661
Merge branch 'main' into chat-summary-7v0c
jaaydenh Aug 24, 2026
2feb05c
fix: address chat summary review feedback
jaaydenh Aug 27, 2026
68d1b48
fix: recover active chat summary generation
jaaydenh Aug 27, 2026
54f54af
fix: finish chat summary generation state
jaaydenh Aug 27, 2026
06f70a8
fix: ignore superseded summary failures
jaaydenh Aug 27, 2026
f6c41ca
fix(coderd/database): add summary generation migration
jaaydenh Aug 27, 2026
f113a51
fix: preserve summary replay timeout
jaaydenh Aug 27, 2026
840425e
docs(coderd/x/chatd): add summary architecture TODOs
jaaydenh Aug 27, 2026
91a04ce
fix: keep summary completion state consistent
jaaydenh Aug 27, 2026
99ef133
fix(site): refetch active chat after reconnect
jaaydenh Aug 27, 2026
2749c3d
fix: fence chat summary generation events
jaaydenh Aug 27, 2026
2d6a39b
fix(site): refresh chat cost after summary timeout
jaaydenh Aug 27, 2026
b8c059d
fix: order chat summary generations
jaaydenh Aug 27, 2026
1f29f74
test(site): exercise chat watch reconnect
jaaydenh Aug 27, 2026
66dda55
fix(site): refetch summary after generation timeout
jaaydenh Aug 27, 2026
70edfb9
chore: merge main into chat-summary-7v0c
jaaydenh Aug 28, 2026
cb17cce
chore: merge latest main into chat-summary-7v0c
jaaydenh Aug 28, 2026
7506cba
chore: resolve main merge conflicts in chat summaries
jaaydenh Sep 8, 2026
2fc72f3
Merge branch 'main' into chat-summary-7v0c
jaaydenh Sep 8, 2026
c714624
refactor: remove durable chat summary loading state
jaaydenh Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -4660,7 +4660,7 @@ const (
// Subagent summaries reuse the final report instead of generating
// text, so their work timeout only covers two database round trips.
subagentReportSummaryTimeout = 15 * time.Second
// Bound the extracted report snippet near the 1-3 sentence
// Bound the extracted report snippet near the headline of the
// generated summaries that root chats get, so subagent and parent
// summary panels read the same.
subagentReportSummaryMaxRunes = 300
Expand Down
102 changes: 81 additions & 21 deletions coderd/x/chatd/quickgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -914,11 +914,14 @@ func generateManualTitle(
}

const chatSummaryGenerationPrompt = "You summarize an AI coding chat for a quick-reference popover. " +
"Populate the summary field with 1 to 3 plain sentences describing what the conversation is about and what was accomplished or attempted. " +
"Populate the headline field with one sentence naming what the conversation is about and its outcome. " +
"Populate the bullets field with 2 to 4 short bullets covering what was done or attempted, each a single line. " +
"Leave the bullets field empty when the headline already covers the whole chat, rather than padding it with filler. " +
"Write about the conversation in the third person. " +
"Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages. " +
"Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages, " +
"wrapping them in backticks. " +
"Do not address the user, give instructions, or continue the task. " +
"No markdown, lists, headings, code fences, or surrounding quotes."
"No headings, code fences, tables, or nested lists."

const (
// Bound the transcript so the summary call stays cheap and within context;
Expand All @@ -927,14 +930,19 @@ const (
// Cap a single turn so one long message cannot dominate the budget.
summaryTranscriptPerMessageMaxRunes = 4000
summaryMaxOutputTokens = 512
// Reject pathologically long or verbose summaries, with slack over the
// 1-3 sentence target.
summaryMaxRunes = 1000
summaryMaxSentences = 6
// Reject pathologically long or verbose summaries.
summaryMaxRunes = 600
summaryHeadlineMaxRunes = 200
summaryHeadlineMaxSentences = 2
summaryBulletMaxRunes = 160
// Upper bound only; requiring bullets would pad trivial chats with
// filler or reject them, leaving the panel empty.
summaryMaxBullets = 4
)

type generatedChatSummary struct {
Summary string `json:"summary" description:"1-3 sentence summary of the whole chat"`
Headline string `json:"headline" description:"One sentence naming what the chat is about and its outcome"`
Bullets []string `json:"bullets" description:"2-4 short bullets, each one line, covering what was done or attempted; empty when the headline already covers the whole chat"`
}

// renderChatSummaryTranscript renders chat history as plain text for summary
Expand Down Expand Up @@ -1034,12 +1042,16 @@ func boundTranscriptHeadTail(lines []string, maxRunes int) string {
}

func summaryObjectCall(resolved resolvedModelCall) fantasy.ObjectCall {
return resolved.newObjectCall("chat_summary", "Summarize the whole chat in 1-3 sentences.", summaryMaxOutputTokens)
return resolved.newObjectCall(
"chat_summary",
"Summarize the whole chat as a one-sentence headline plus up to 4 short bullets.",
summaryMaxOutputTokens,
)
}

// generateChatSummary generates a 1-3 sentence whole-chat summary from a
// transcript. A blank or invalid result returns an error so callers preserve
// any existing summary rather than clearing it.
// generateChatSummary generates a headline-plus-bullets summary from a
// transcript, serialized to markdown. A blank or invalid result returns an
// error so callers preserve any existing summary rather than clearing it.
func generateChatSummary(
ctx context.Context,
model fantasy.LanguageModel,
Expand All @@ -1066,22 +1078,70 @@ func generateChatSummary(
return "", usage, xerrors.Errorf("generate chat summary: %w", err)
}

summary := normalizeShortTextOutput(result.Object.Summary)
summary := generatedChatSummary{
Headline: normalizeSummaryField(result.Object.Headline),
Bullets: normalizeSummaryBullets(result.Object.Bullets),
}
if err := validateGeneratedChatSummary(summary); err != nil {
return "", result.Usage, err
}
return summary, result.Usage, nil
return formatChatSummaryMarkdown(summary.Headline, summary.Bullets), result.Usage, nil
Comment thread
jaaydenh marked this conversation as resolved.
}

// normalizeSummaryField collapses a field onto one line. Unlike
// normalizeShortTextOutput it preserves backticks, keeping inline code spans
// balanced.
func normalizeSummaryField(text string) string {
text = strings.Trim(strings.TrimSpace(text), "\"'")
return strings.Join(strings.Fields(text), " ")
}

func validateGeneratedChatSummary(summary string) error {
if summary == "" {
return xerrors.New("generated chat summary was empty")
func normalizeSummaryBullets(bullets []string) []string {
normalized := make([]string, 0, len(bullets))
for _, bullet := range bullets {
if bullet = normalizeSummaryField(bullet); bullet != "" {
normalized = append(normalized, bullet)
}
}
if len([]rune(summary)) > summaryMaxRunes {
return xerrors.Errorf("generated chat summary exceeded %d runes", summaryMaxRunes)
return normalized
}

// formatChatSummaryMarkdown renders a headline paragraph plus an optional
// bullet list. Bullets must already be normalized: no blanks, no newlines.
func formatChatSummaryMarkdown(headline string, bullets []string) string {
headline = strings.TrimSpace(headline)
if len(bullets) == 0 {
return headline
}
if countSentenceTerminators(summary) > summaryMaxSentences {
return xerrors.Errorf("generated chat summary exceeded %d sentences", summaryMaxSentences)
return strings.TrimSpace(headline + "\n\n- " + strings.Join(bullets, "\n- "))
}

// validateGeneratedChatSummary checks the structured fields rather than the
// rendered markdown: bullets omit trailing punctuation, so a sentence count
// over the serialized string would pass almost anything.
func validateGeneratedChatSummary(summary generatedChatSummary) error {
if summary.Headline == "" {
return xerrors.New("generated chat summary headline was empty")
}
if len([]rune(summary.Headline)) > summaryHeadlineMaxRunes {
return xerrors.Errorf("generated chat summary headline exceeded %d runes", summaryHeadlineMaxRunes)
}
if countSentenceTerminators(summary.Headline) > summaryHeadlineMaxSentences {
return xerrors.Errorf("generated chat summary headline exceeded %d sentences", summaryHeadlineMaxSentences)
}
if len(summary.Bullets) > summaryMaxBullets {
return xerrors.Errorf(
"generated chat summary had %d bullets, want at most %d",
len(summary.Bullets), summaryMaxBullets,
)
}
for _, bullet := range summary.Bullets {
if len([]rune(bullet)) > summaryBulletMaxRunes {
return xerrors.Errorf("generated chat summary bullet exceeded %d runes", summaryBulletMaxRunes)
}
}
if rendered := formatChatSummaryMarkdown(summary.Headline, summary.Bullets); len([]rune(rendered)) > summaryMaxRunes {
return xerrors.Errorf("generated chat summary exceeded %d runes", summaryMaxRunes)
}
return nil
}
Expand Down
168 changes: 160 additions & 8 deletions coderd/x/chatd/summarygen_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,89 @@ func TestShouldGenerateChatSummary(t *testing.T) {
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."))
validBullets := []string{"Traced the race in `cache.go`", "Added a regression test"}

tests := []struct {
name string
summary generatedChatSummary
wantErr bool
}{
{
name: "Valid",
summary: generatedChatSummary{Headline: "Fixed the flaky CI job.", Bullets: validBullets},
},
{
name: "EmptyHeadline",
summary: generatedChatSummary{Bullets: validBullets},
wantErr: true,
},
{
name: "HeadlineTooLong",
summary: generatedChatSummary{
Headline: strings.Repeat("a", summaryHeadlineMaxRunes+1),
Bullets: validBullets,
},
wantErr: true,
},
{
name: "HeadlineTooManySentences",
summary: generatedChatSummary{
Headline: "One. Two. Three.",
Bullets: validBullets,
},
wantErr: true,
},
{
name: "SingleBullet",
summary: generatedChatSummary{Headline: "Fixed it.", Bullets: []string{"Only one"}},
},
{
// A trivial chat is fully described by its headline.
name: "NoBullets",
summary: generatedChatSummary{Headline: "Fixed a typo in `README.md`."},
},
{
name: "TooManyBullets",
summary: generatedChatSummary{
Headline: "Fixed it.",
Bullets: []string{"One", "Two", "Three", "Four", "Five"},
},
wantErr: true,
},
{
name: "BulletTooLong",
summary: generatedChatSummary{
Headline: "Fixed it.",
Bullets: []string{"Fine", strings.Repeat("b", summaryBulletMaxRunes+1)},
},
wantErr: true,
},
{
name: "SerializedTooLong",
summary: generatedChatSummary{
Headline: strings.Repeat("a", summaryHeadlineMaxRunes),
Bullets: []string{
strings.Repeat("b", summaryBulletMaxRunes),
strings.Repeat("c", summaryBulletMaxRunes),
strings.Repeat("d", summaryBulletMaxRunes),
},
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := validateGeneratedChatSummary(tt.summary)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}

func TestCountSentenceTerminators(t *testing.T) {
Expand All @@ -239,10 +318,83 @@ func TestCountSentenceTerminators(t *testing.T) {
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.",
))
// Dotted identifiers must not push a valid headline over the sentence cap.
require.NoError(t, validateGeneratedChatSummary(generatedChatSummary{
Headline: "Refactored pkg.cmd.server and auth.rbac.Policy in main.go and util.go.",
Bullets: []string{"Updated call sites", "Added coverage in foo_test.go"},
}))
}

func TestNormalizeSummaryField(t *testing.T) {
t.Parallel()

tests := []struct {
name string
text string
want string
}{
{name: "Empty", text: " ", want: ""},
{name: "CollapsesNewlines", text: "Fixed the race\nin cache.go", want: "Fixed the race in cache.go"},
{name: "CollapsesRuns", text: "Fixed the\t\trace", want: "Fixed the race"},
{name: "StripsSurroundingQuotes", text: `"Fixed the race"`, want: "Fixed the race"},
{
// normalizeShortTextOutput would strip this and unbalance the span.
name: "PreservesTrailingBacktick",
text: "Fixed `cache.go`",
want: "Fixed `cache.go`",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

require.Equal(t, tt.want, normalizeSummaryField(tt.text))
})
}
}

func TestNormalizeSummaryBullets(t *testing.T) {
t.Parallel()

require.Equal(t,
[]string{"First bullet", "Second bullet"},
normalizeSummaryBullets([]string{" First\nbullet ", " ", "Second bullet", ""}),
)
require.Empty(t, normalizeSummaryBullets(nil))
}

func TestFormatChatSummaryMarkdown(t *testing.T) {
t.Parallel()

tests := []struct {
name string
headline string
bullets []string
want string
}{
{
name: "HeadlineOnly",
headline: "Fixed the flaky CI job.",
want: "Fixed the flaky CI job.",
},
{
// Without the blank line, CommonMark folds the first bullet
// into the headline paragraph.
name: "HeadlineAndBullets",
headline: "Fixed the flaky CI job.",
bullets: []string{"Traced the race", "Added a test"},
want: "Fixed the flaky CI job.\n\n- Traced the race\n- Added a test",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

require.Equal(t, tt.want, formatChatSummaryMarkdown(tt.headline, tt.bullets))
})
}
}

func TestSubagentReportSummarySnippet(t *testing.T) {
Expand Down
13 changes: 11 additions & 2 deletions site/src/@types/storybook.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@ import type { Permissions } from "#/modules/permissions";

declare module "@storybook/react-vite" {
type WebSocketEvent =
| { event: "message"; data: string }
| { event: "open" | "error" | "close" };
| {
event: "message";
data: string;
delayMs?: number;
connectionIndex?: number;
}
| {
event: "open" | "error" | "close";
delayMs?: number;
connectionIndex?: number;
};
interface Parameters {
features?: (FeatureName | ({ name: FeatureName } & Partial<Feature>))[];
experiments?: Experiments;
Expand Down
Loading
Loading