diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index c93bc9b7259..aea63d849f2 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -551,9 +551,6 @@ func validateGeneratedTitle(title string) error { if title == "" { return xerrors.New("generated title was empty") } - if len(strings.Fields(title)) > 8 { - return xerrors.New("generated title exceeded 8 words") - } return nil } @@ -652,11 +649,20 @@ func titlePasteText( return pasteText, nil } +// titleMaxWords caps generated titles at the prompt's stated 2-8 word +// budget. Quickgen pins temperature, so a model that overshoots the +// budget for a given conversation keeps overshooting on retry; keeping +// the first words of an otherwise good title beats failing generation. +const titleMaxWords = 8 + func normalizeTitleOutput(title string) string { title = normalizeShortTextOutput(title) if title == "" { return "" } + if words := strings.Fields(title); len(words) > titleMaxWords { + title = strings.Join(words[:titleMaxWords], " ") + } return truncateRunes(title, 80) } diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index fe67c2da4a4..a6a8123191b 100644 --- a/coderd/x/chatd/quickgen_internal_test.go +++ b/coderd/x/chatd/quickgen_internal_test.go @@ -861,6 +861,32 @@ func TestNormalizeTurnStatusLabel(t *testing.T) { } } +func Test_normalizeTitleOutput(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + want string + }{ + {name: "empty after normalization", input: " \"\" ", want: ""}, + {name: "collapses whitespace and strips quotes", input: "\"Fix pq duplicate\tkey\"", want: "Fix pq duplicate key"}, + {name: "keeps titles within the word budget", input: "Review risky changes in acme/webapp PR #123", want: "Review risky changes in acme/webapp PR #123"}, + { + name: "truncates overlong titles to the word budget", + input: "Re-capture Tasks-enabled evidence for the Coder pull request feature flag", + want: "Re-capture Tasks-enabled evidence for the Coder pull request", + }, + {name: "truncates to 80 runes", input: strings.Repeat("a", 100), want: strings.Repeat("a", 80)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, normalizeTitleOutput(tc.input)) + }) + } +} + func TestFallbackTurnStatusLabel(t *testing.T) { t.Parallel() @@ -946,6 +972,31 @@ func TestGenerateStructuredTitleWithUsage_DropsRejectedTemperature(t *testing.T) "generation should retry without temperature after the model rejects it") } +func TestGenerateStructuredTitleWithUsage_TruncatesOverlongTitle(t *testing.T) { + t.Parallel() + + model := &chattest.FakeModel{ + GenerateObjectFn: func(_ context.Context, _ fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { + return &fantasy.ObjectResponse{ + Object: map[string]any{ + "title": "Re-capture Tasks-enabled evidence for the Coder pull request feature flag", + }, + }, nil + }, + } + + title, _, err := generateStructuredTitleWithUsage( + t.Context(), + model, + titleObjectCall(resolvedModelCall{}), + titleGenerationPrompt, + "re-capture UI evidence for a Coder pull request", + ) + require.NoError(t, err) + require.Equal(t, "Re-capture Tasks-enabled evidence for the Coder pull request", title, + "an overlong generated title should be truncated to the word budget, not rejected") +} + func newOpenAICompatStructuredOutputServer( t *testing.T, toolName string,