Skip to content
7 changes: 5 additions & 2 deletions coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -4726,7 +4726,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 Expand Up @@ -4948,7 +4948,10 @@ func (p *Server) storeSubagentReportSummary(
slog.F("chat_id", chat.ID), slog.Error(err))
return
}
summary := subagentReportSummarySnippet(report)
// Subagent summaries are extracted from an existing report rather than
// generated, so they have no bullets; the serializer keeps the headline
// as-is and only adds structure when bullets exist.
summary := formatChatSummaryMarkdown(subagentReportSummarySnippet(report), nil)
if summary == "" {
return
}
Expand Down
123 changes: 102 additions & 21 deletions coderd/x/chatd/quickgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -969,11 +969,13 @@ 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. " +
"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 @@ -982,14 +984,20 @@ 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. The caps apply to the
// structured fields before serialization, plus a ceiling on the rendered
// markdown so the panel stays scannable.
summaryMaxRunes = 600
summaryHeadlineMaxRunes = 200
summaryHeadlineMaxSentences = 2
summaryBulletMaxRunes = 160
summaryMinBullets = 2
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"`
}

// renderChatSummaryTranscript renders chat history as plain text for summary
Expand Down Expand Up @@ -1088,9 +1096,10 @@ func boundTranscriptHeadTail(lines []string, maxRunes int) string {
return out.String()
}

// 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 whole-chat summary from a transcript as a
// one-sentence headline plus 2-4 bullets, serialized to markdown by
// formatChatSummaryMarkdown. 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 Down Expand Up @@ -1123,7 +1132,7 @@ func generateChatSummary(
result, genErr = object.Generate[generatedChatSummary](retryCtx, model, fantasy.ObjectCall{
Prompt: prompt,
SchemaName: "chat_summary",
SchemaDescription: "Summarize the whole chat in 1-3 sentences.",
SchemaDescription: "Summarize the whole chat as a one-sentence headline plus 2-4 short bullets.",
MaxOutputTokens: &maxOutputTokens,
})
return genErr
Expand All @@ -1136,22 +1145,94 @@ 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the obsolete sentence-summary contract

This return now serializes a headline followed by Markdown bullets, but generateChatSummary is still documented immediately above as generating a "1-3 sentence whole-chat summary." That stale contract can mislead future callers and tests about the persisted format; update the function comment to describe the headline-plus-bullets output.

AGENTS.md reference: AGENTS.md:L180-L183

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dff5f06. Correct, that comment was stale.

The same change had also invalidated a second comment you did not flag: coderd/x/chatd/chatd.go:4729 bounded the extracted subagent report snippet "near the 1-3 sentence generated summaries that root chats get". The snippet caps themselves are unchanged at 300 runes / 3 sentences, which now corresponds to the headline rather than a whole summary, so I updated that wording too.

Swept for other references to the old contract and found none; ARCHITECTURE.md documents no summary format contract, so it needed no change.

}

func validateGeneratedChatSummary(summary string) error {
if summary == "" {
return xerrors.New("generated chat summary was empty")
// normalizeSummaryField collapses internal whitespace so a field stays on one
// line, and strips surrounding quotes. Unlike normalizeShortTextOutput it
// preserves backticks, so a field ending in an inline code span keeps a
// balanced pair.
func normalizeSummaryField(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
if len([]rune(summary)) > summaryMaxRunes {
return xerrors.Errorf("generated chat summary exceeded %d runes", summaryMaxRunes)

text = strings.Trim(text, "\"'")
return strings.Join(strings.Fields(text), " ")
}

// normalizeSummaryBullets normalizes each bullet and drops the ones that
// normalize to nothing, so blank model output does not render as an empty
// list item.
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 countSentenceTerminators(summary) > summaryMaxSentences {
return xerrors.Errorf("generated chat summary exceeded %d sentences", summaryMaxSentences)
return normalized
}

// formatChatSummaryMarkdown renders the stored summary as a headline paragraph
// followed by an optional bullet list, separated by a blank line. Both summary
// producers go through here so the stored format stays consistent.
func formatChatSummaryMarkdown(headline string, bullets []string) string {
headline = strings.TrimSpace(headline)
if len(bullets) == 0 {
return headline
}

var out strings.Builder
_, _ = out.WriteString(headline)
_, _ = out.WriteString("\n")
for _, bullet := range bullets {
if bullet = strings.TrimSpace(bullet); bullet != "" {
_, _ = out.WriteString("\n- ")
_, _ = out.WriteString(bullet)
}
}
return strings.TrimSpace(out.String())
}

// validateGeneratedChatSummary checks the structured fields before they are
// serialized. Validating here rather than over the rendered markdown keeps the
// sentence cap meaningful: bullets routinely 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) < summaryMinBullets || len(summary.Bullets) > summaryMaxBullets {
return xerrors.Errorf(
"generated chat summary had %d bullets, want %d to %d",
len(summary.Bullets), summaryMinBullets, summaryMaxBullets,
)
}
for _, bullet := range summary.Bullets {
if len([]rune(bullet)) > summaryBulletMaxRunes {
return xerrors.Errorf("generated chat summary bullet exceeded %d runes", summaryBulletMaxRunes)
}
if strings.ContainsAny(bullet, "\n\r") {
return xerrors.New("generated chat summary bullet contained a newline")
}
}
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
190 changes: 182 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,98 @@ 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: "TooFewBullets",
summary: generatedChatSummary{Headline: "Fixed it.", Bullets: []string{"Only one"}},
wantErr: true,
},
{
name: "NoBullets",
summary: generatedChatSummary{Headline: "Fixed it."},
wantErr: true,
},
{
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: "BulletWithNewline",
summary: generatedChatSummary{
Headline: "Fixed it.",
Bullets: []string{"Fine", "Broken\nacross lines"},
},
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 +327,96 @@ 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 trailing backtick and
// leave an unbalanced inline code 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.",
},
{
// A blank line must separate the paragraph from the list, or
// 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",
},
{
name: "DropsEmptyBullets",
headline: "Fixed it.",
bullets: []string{"Kept", " ", "Also kept"},
want: "Fixed it.\n\n- Kept\n- Also kept",
},
{
name: "AllBulletsEmptyKeepsHeadline",
headline: "Fixed it.",
bullets: []string{" ", ""},
want: "Fixed it.",
},
}

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
Loading
Loading