fix(coderd/x/chatd): use structured output for chat title generation (#23909)

Chat title generation used free-form text completion, which let models
respond conversationally instead of producing a title. Review chats
started with GitHub URLs were especially affected — models would say "I
don't have the ability to browse external links" and that string became
the persisted title.

Replace the raw-text `generateShortText` path with structured output via
`object.Generate[generatedTitle]`. Both auto-title and manual retitle
now go through the same typed contract: the model must return a JSON
object with a `title` field, validated and normalized before
persistence. Invalid outputs (empty, too long) are rejected and retried
through the existing candidate-model fallback loop.
This commit is contained in:
Ethan
2026-04-02 14:13:27 +11:00
committed by GitHub
parent 3a4a0b7270
commit fc1e0beb3b
4 changed files with 116 additions and 48 deletions
+12 -12
View File
@@ -62,8 +62,8 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
}
modelConfig := database.ChatModelConfig{
ID: modelConfigID,
Provider: "anthropic",
Model: "claude-haiku-4-5",
Provider: "openai",
Model: "gpt-4o-mini",
ContextLimit: 8192,
}
updatedChat := chat
@@ -85,9 +85,9 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
require.NoError(t, err)
defer cancelSub()
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
require.Equal(t, "claude-haiku-4-5", req.Model)
return chattest.AnthropicNonStreamingResponse(wantTitle)
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
require.Equal(t, "gpt-4o-mini", req.Model)
return chattest.OpenAINonStreamingResponse("{\"title\":\"" + wantTitle + "\"}")
})
server := &Server{
@@ -99,7 +99,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{
Provider: "anthropic",
Provider: "openai",
APIKey: "test-key",
BaseUrl: serverURL,
}}, nil)
@@ -221,8 +221,8 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
lockedChat.StartedAt = sql.NullTime{Time: time.Now(), Valid: true}
modelConfig := database.ChatModelConfig{
ID: modelConfigID,
Provider: "anthropic",
Model: "claude-haiku-4-5",
Provider: "openai",
Model: "gpt-4o-mini",
ContextLimit: 8192,
}
updatedChat := lockedChat
@@ -247,9 +247,9 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
require.NoError(t, err)
defer cancelSub()
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
require.Equal(t, "claude-haiku-4-5", req.Model)
return chattest.AnthropicNonStreamingResponse(wantTitle)
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
require.Equal(t, "gpt-4o-mini", req.Model)
return chattest.OpenAINonStreamingResponse("{\"title\":\"" + wantTitle + "\"}")
})
server := &Server{
@@ -261,7 +261,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{
Provider: "anthropic",
Provider: "openai",
APIKey: "test-key",
BaseUrl: serverURL,
}}, nil)
+5 -1
View File
@@ -670,7 +670,11 @@ func (s *openAIServer) writeResponsesAPINonStreaming(w http.ResponseWriter, resp
"created": resp.Created,
"model": resp.Model,
"output": outputs,
"usage": resp.Usage,
"usage": map[string]interface{}{
"input_tokens": resp.Usage.PromptTokens,
"output_tokens": resp.Usage.CompletionTokens,
"total_tokens": resp.Usage.TotalTokens,
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
+79 -13
View File
@@ -2,12 +2,14 @@ package chatd
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"charm.land/fantasy"
"charm.land/fantasy/object"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
fantasyazure "charm.land/fantasy/providers/azure"
fantasybedrock "charm.land/fantasy/providers/bedrock"
@@ -27,6 +29,7 @@ import (
)
const titleGenerationPrompt = "Write a short title for the user's message. " +
"Populate the title field with the result. " +
"Return only the title text in 2-8 words. " +
"Do not answer the user or describe the title-writing task. " +
"Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages. " +
@@ -89,6 +92,10 @@ func normalizeShortTextOutput(text string) string {
return strings.Join(strings.Fields(text), " ")
}
type generatedTitle struct {
Title string `json:"title" description:"Short descriptive chat title"`
}
// maybeGenerateChatTitle generates an AI title for the chat when
// appropriate (first user message, no assistant reply yet, and the
// current title is either empty or still the fallback truncation).
@@ -173,17 +180,79 @@ func generateTitle(
model fantasy.LanguageModel,
input string,
) (string, error) {
title, _, err := generateShortText(ctx, model, titleGenerationPrompt, input)
title, _, err := generateStructuredTitle(ctx, model, titleGenerationPrompt, input)
if err != nil {
return "", err
}
title = normalizeTitleOutput(title)
if title == "" {
return "", xerrors.New("generated title was empty")
}
return title, nil
}
func generateStructuredTitle(
ctx context.Context,
model fantasy.LanguageModel,
systemPrompt string,
userInput string,
) (string, fantasy.Usage, error) {
userInput = strings.TrimSpace(userInput)
if userInput == "" {
return "", fantasy.Usage{}, xerrors.New("title input was empty")
}
prompt := fantasy.Prompt{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: systemPrompt},
},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: userInput},
},
},
}
var maxOutputTokens int64 = 256
var result *fantasy.ObjectResult[generatedTitle]
err := chatretry.Retry(ctx, func(retryCtx context.Context) error {
var genErr error
result, genErr = object.Generate[generatedTitle](retryCtx, model, fantasy.ObjectCall{
Prompt: prompt,
SchemaName: "propose_title",
SchemaDescription: "Propose a short chat title.",
MaxOutputTokens: &maxOutputTokens,
})
return genErr
}, nil)
if err != nil {
// Extract usage from the error when available so that
// failed attempts are still accounted for in usage tracking.
var usage fantasy.Usage
var noObjErr *fantasy.NoObjectGeneratedError
if errors.As(err, &noObjErr) {
usage = noObjErr.Usage
}
return "", usage, xerrors.Errorf("generate structured title: %w", err)
}
title := normalizeTitleOutput(result.Object.Title)
if err := validateGeneratedTitle(title); err != nil {
return "", result.Usage, err
}
return title, result.Usage, nil
}
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
}
// titleInput returns the first user message text and whether title
// generation should proceed. It returns false when the chat already
// has assistant/tool replies, has more than one visible user message,
@@ -400,7 +469,8 @@ func renderManualTitlePrompt(
_, _ = prompt.WriteString(value)
}
write("Write a short title for this AI coding conversation.\n\n")
write("Write a short title for this AI coding conversation.\n")
write("Populate the title field with the result.\n\n")
write("Primary user objective:\n<primary_objective>\n")
write(firstUserText)
write("\n</primary_objective>")
@@ -420,6 +490,7 @@ func renderManualTitlePrompt(
write("\n\nRequirements:\n")
write("- Return only the title text in 2-8 words.\n")
write("- Populate the title field only.\n")
write("- Do not answer the user or describe the title-writing task.\n")
write("- Preserve specific identifiers (PR numbers, repo names, file paths, function names, error messages).\n")
write("- If the conversation is short or vague, stay close to the user's wording.\n")
@@ -458,19 +529,14 @@ func generateManualTitle(
userInput = strings.TrimSpace(firstUserText)
}
title, usage, err := generateShortText(
title, usage, err := generateStructuredTitle(
titleCtx,
fallbackModel,
systemPrompt,
userInput,
)
if err != nil {
return "", fantasy.Usage{}, err
}
title = normalizeTitleOutput(title)
if title == "" {
return "", usage, xerrors.New("generated title was empty")
return "", usage, err
}
return title, usage, nil
+20 -22
View File
@@ -376,7 +376,7 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) {
}
model := &stubModel{
generateFn: func(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
generateObjectFn: func(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
deadline, ok := ctx.Deadline()
require.True(t, ok, "manual title generation should set a deadline")
require.WithinDuration(
@@ -386,11 +386,8 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) {
2*time.Second,
)
require.Len(t, call.Prompt, 2)
return &fantasy.Response{
Content: fantasy.ResponseContent{
fantasy.TextContent{Text: "Refresh title"},
},
}, nil
require.Equal(t, "propose_title", call.SchemaName)
return &fantasy.ObjectResponse{Object: map[string]any{"title": "Refresh title"}}, nil
},
}
@@ -417,7 +414,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) {
}
model := &stubModel{
generateFn: func(_ context.Context, call fantasy.Call) (*fantasy.Response, error) {
generateObjectFn: func(_ context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
require.Len(t, call.Prompt, 2)
systemText, ok := call.Prompt[0].Content[0].(fantasy.TextPart)
require.True(t, ok)
@@ -426,11 +423,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) {
userText, ok := call.Prompt[1].Content[0].(fantasy.TextPart)
require.True(t, ok)
require.Equal(t, truncateRunes(longFirstUserText, 1000), userText.Text)
return &fantasy.Response{
Content: fantasy.ResponseContent{
fantasy.TextContent{Text: "Refresh title"},
},
}, nil
return &fantasy.ObjectResponse{Object: map[string]any{"title": "Refresh title"}}, nil
},
}
@@ -455,11 +448,9 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T)
}
model := &stubModel{
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
return &fantasy.Response{
Content: fantasy.ResponseContent{
fantasy.TextContent{Text: "\"\""},
},
generateObjectFn: func(_ context.Context, _ fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
return &fantasy.ObjectResponse{
Object: map[string]any{"title": "\"\""},
Usage: fantasy.Usage{
InputTokens: 11,
OutputTokens: 7,
@@ -533,13 +524,17 @@ func Test_generateShortText_NormalizesQuotedOutput(t *testing.T) {
}
type stubModel struct {
generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error)
generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error)
generateObjectFn func(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error)
}
func (m *stubModel) Generate(
ctx context.Context,
call fantasy.Call,
) (*fantasy.Response, error) {
if m.generateFn == nil {
return nil, xerrors.New("generate not implemented")
}
return m.generateFn(ctx, call)
}
@@ -550,11 +545,14 @@ func (*stubModel) Stream(
return nil, xerrors.New("stream not implemented")
}
func (*stubModel) GenerateObject(
context.Context,
fantasy.ObjectCall,
func (m *stubModel) GenerateObject(
ctx context.Context,
call fantasy.ObjectCall,
) (*fantasy.ObjectResponse, error) {
return nil, xerrors.New("generate object not implemented")
if m.generateObjectFn == nil {
return nil, xerrors.New("generate object not implemented")
}
return m.generateObjectFn(ctx, call)
}
func (*stubModel) StreamObject(