refactor: add chat_message_role enum and content_version column (#23042)

Migration 000434 converts chat_messages.role from text to a Postgres
enum, rebuilds the partial index, and adds content_version smallint.
The column is backfilled with DEFAULT 0, then the default is dropped
so future inserts must set it explicitly.

Version 0 uses the role-aware heuristic from #22958. Version 1 (all
new inserts) stores []ChatMessagePart JSON for all roles, including
system messages. ParseContent takes database.ChatMessage directly
and dispatches on version internally. Unknown versions error.

All string(codersdk.ChatMessageRole*) casts at DB write sites are
replaced with database.ChatMessageRole* constants from sqlc.

Refs #22958
This commit is contained in:
Mathias Fredriksson
2026-03-13 16:47:36 +00:00
committed by GitHub
parent bdbcd3428b
commit 4a79af1a0d
17 changed files with 455 additions and 208 deletions
+52 -45
View File
@@ -268,7 +268,9 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
systemPrompt := strings.TrimSpace(opts.SystemPrompt)
if systemPrompt != "" {
systemContent, err := json.Marshal(systemPrompt)
systemContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText(systemPrompt),
})
if err != nil {
return xerrors.Errorf("marshal system prompt: %w", err)
}
@@ -279,11 +281,9 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
UUID: opts.ModelConfigID,
Valid: true,
},
Role: string(codersdk.ChatMessageRoleSystem),
Content: pqtype.NullRawMessage{
RawMessage: systemContent,
Valid: len(systemContent) > 0,
},
Role: database.ChatMessageRoleSystem,
ContentVersion: chatprompt.CurrentContentVersion,
Content: systemContent,
Visibility: database.ChatMessageVisibilityModel,
InputTokens: sql.NullInt64{},
OutputTokens: sql.NullInt64{},
@@ -309,7 +309,8 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
UUID: opts.ModelConfigID,
Valid: true,
},
Role: string(codersdk.ChatMessageRoleUser),
Role: database.ChatMessageRoleUser,
ContentVersion: chatprompt.CurrentContentVersion,
Content: userContent,
CreatedBy: uuid.NullUUID{UUID: opts.OwnerID, Valid: opts.OwnerID != uuid.Nil},
Visibility: database.ChatMessageVisibilityBoth,
@@ -523,7 +524,7 @@ func (p *Server) EditMessage(
if existing.ChatID != opts.ChatID {
return ErrEditedMessageNotFound
}
if existing.Role != "user" {
if existing.Role != database.ChatMessageRoleUser {
return ErrEditedMessageNotUser
}
@@ -897,7 +898,8 @@ func insertUserMessageAndSetPending(
message, err := insertChatMessageWithStore(ctx, store, database.InsertChatMessageParams{
ChatID: lockedChat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: string(codersdk.ChatMessageRoleUser),
Role: database.ChatMessageRoleUser,
ContentVersion: chatprompt.CurrentContentVersion,
Content: content,
CreatedBy: uuid.NullUUID{UUID: createdBy, Valid: createdBy != uuid.Nil},
Visibility: database.ChatMessageVisibilityBoth,
@@ -1950,9 +1952,10 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
nextQueued, popErr := tx.PopNextQueuedMessage(cleanupCtx, chat.ID)
if popErr == nil {
msg, insertErr := tx.InsertChatMessage(cleanupCtx, database.InsertChatMessageParams{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: latestChat.LastModelConfigID, Valid: true},
Role: string(codersdk.ChatMessageRoleUser),
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: latestChat.LastModelConfigID, Valid: true},
Role: database.ChatMessageRoleUser,
ContentVersion: chatprompt.CurrentContentVersion,
Content: pqtype.NullRawMessage{
RawMessage: nextQueued.Content,
Valid: len(nextQueued.Content) > 0,
@@ -2345,15 +2348,16 @@ func (p *Server) runChat(
hasUsage := step.Usage != (fantasy.Usage{})
assistantMessage, insertErr := tx.InsertChatMessage(persistCtx, database.InsertChatMessageParams{
ChatID: chat.ID,
CreatedBy: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: string(codersdk.ChatMessageRoleAssistant),
Content: assistantContent,
Visibility: database.ChatMessageVisibilityBoth,
InputTokens: usageNullInt64(step.Usage.InputTokens, hasUsage),
OutputTokens: usageNullInt64(step.Usage.OutputTokens, hasUsage),
TotalTokens: usageNullInt64(step.Usage.TotalTokens, hasUsage),
ChatID: chat.ID,
CreatedBy: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
ContentVersion: chatprompt.CurrentContentVersion,
Content: assistantContent,
Visibility: database.ChatMessageVisibilityBoth,
InputTokens: usageNullInt64(step.Usage.InputTokens, hasUsage),
OutputTokens: usageNullInt64(step.Usage.OutputTokens, hasUsage),
TotalTokens: usageNullInt64(step.Usage.TotalTokens, hasUsage),
ReasoningTokens: usageNullInt64(
step.Usage.ReasoningTokens,
hasUsage,
@@ -2383,7 +2387,8 @@ func (p *Server) runChat(
ChatID: chat.ID,
CreatedBy: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: string(codersdk.ChatMessageRoleTool),
Role: database.ChatMessageRoleTool,
ContentVersion: chatprompt.CurrentContentVersion,
Content: resultContent,
Visibility: database.ChatMessageVisibilityBoth,
InputTokens: sql.NullInt64{},
@@ -2676,7 +2681,9 @@ func (p *Server) persistChatContextSummary(
return nil
}
systemContent, err := json.Marshal(result.SystemSummary)
systemContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText(result.SystemSummary),
})
if err != nil {
return xerrors.Errorf("encode system summary: %w", err)
}
@@ -2718,14 +2725,12 @@ func (p *Server) persistChatContextSummary(
txErr := p.db.InTx(func(tx database.Store) error {
_, txErr := tx.InsertChatMessage(ctx, database.InsertChatMessageParams{
ChatID: chatID,
CreatedBy: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: string(codersdk.ChatMessageRoleUser),
Content: pqtype.NullRawMessage{
RawMessage: systemContent,
Valid: len(systemContent) > 0,
},
ChatID: chatID,
CreatedBy: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: database.ChatMessageRoleUser,
ContentVersion: chatprompt.CurrentContentVersion,
Content: systemContent,
Visibility: database.ChatMessageVisibilityModel,
Compressed: sql.NullBool{Bool: true, Valid: true},
InputTokens: sql.NullInt64{},
@@ -2741,12 +2746,13 @@ func (p *Server) persistChatContextSummary(
}
assistantMessage, txErr := tx.InsertChatMessage(ctx, database.InsertChatMessageParams{
ChatID: chatID,
CreatedBy: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: string(codersdk.ChatMessageRoleAssistant),
Content: assistantContent,
Visibility: database.ChatMessageVisibilityUser,
ChatID: chatID,
CreatedBy: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: database.ChatMessageRoleAssistant,
ContentVersion: chatprompt.CurrentContentVersion,
Content: assistantContent,
Visibility: database.ChatMessageVisibilityUser,
Compressed: sql.NullBool{
Bool: true,
Valid: true,
@@ -2765,12 +2771,13 @@ func (p *Server) persistChatContextSummary(
insertedMessages = append(insertedMessages, assistantMessage)
toolMessage, txErr := tx.InsertChatMessage(ctx, database.InsertChatMessageParams{
ChatID: chatID,
CreatedBy: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: string(codersdk.ChatMessageRoleTool),
Content: toolResult,
Visibility: database.ChatMessageVisibilityBoth,
ChatID: chatID,
CreatedBy: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: database.ChatMessageRoleTool,
ContentVersion: chatprompt.CurrentContentVersion,
Content: toolResult,
Visibility: database.ChatMessageVisibilityBoth,
Compressed: sql.NullBool{
Bool: true,
Valid: true,
@@ -3134,10 +3141,10 @@ func (p *Server) maybeSendPushNotification(
msg, err := p.db.GetLastChatMessageByRole(pushCtx, database.GetLastChatMessageByRoleParams{
ChatID: chat.ID,
Role: string(codersdk.ChatMessageRoleAssistant),
Role: database.ChatMessageRoleAssistant,
})
if err == nil {
content, parseErr := chatprompt.ParseContent(codersdk.ChatMessageRoleAssistant, msg.Content)
content, parseErr := chatprompt.ParseContent(msg)
if parseErr == nil {
assistantText := strings.TrimSpace(contentBlocksToText(content))
if assistantText != "" {
+32 -13
View File
@@ -13,12 +13,12 @@ import (
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/chatd"
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/chatd/chattest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
@@ -463,9 +463,13 @@ func TestEditMessageUpdatesAndTruncatesAndClearsQueue(t *testing.T) {
})
require.NoError(t, err)
queuedContent, err := json.Marshal([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("queued"),
})
require.NoError(t, err)
_, err = db.InsertChatQueuedMessage(ctx, database.InsertChatQueuedMessageParams{
ChatID: chat.ID,
Content: json.RawMessage(`"queued"`),
Content: queuedContent,
})
require.NoError(t, err)
@@ -556,14 +560,17 @@ func TestEditMessageRejectsNonUserMessage(t *testing.T) {
})
require.NoError(t, err)
assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("assistant"),
})
require.NoError(t, err)
assistantMessage, err := db.InsertChatMessage(ctx, database.InsertChatMessageParams{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: "assistant",
Content: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`"assistant"`),
Valid: true,
},
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
ContentVersion: chatprompt.CurrentContentVersion,
Content: assistantContent,
Visibility: database.ChatMessageVisibilityBoth,
InputTokens: sql.NullInt64{},
OutputTokens: sql.NullInt64{},
@@ -901,11 +908,17 @@ func TestSubscribeAfterMessageID(t *testing.T) {
// Insert two more messages so we have three total visible
// messages (the initial user message plus these two).
secondContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("second"),
})
require.NoError(t, err)
msg2, err := db.InsertChatMessage(ctx, database.InsertChatMessageParams{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: "assistant",
Content: pqtype.NullRawMessage{RawMessage: json.RawMessage(`"second"`), Valid: true},
Role: database.ChatMessageRoleAssistant,
ContentVersion: chatprompt.CurrentContentVersion,
Content: secondContent,
Visibility: database.ChatMessageVisibilityBoth,
InputTokens: sql.NullInt64{},
OutputTokens: sql.NullInt64{},
@@ -918,11 +931,17 @@ func TestSubscribeAfterMessageID(t *testing.T) {
})
require.NoError(t, err)
thirdContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("third"),
})
require.NoError(t, err)
_, err = db.InsertChatMessage(ctx, database.InsertChatMessageParams{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: "user",
Content: pqtype.NullRawMessage{RawMessage: json.RawMessage(`"third"`), Valid: true},
Role: database.ChatMessageRoleUser,
ContentVersion: chatprompt.CurrentContentVersion,
Content: thirdContent,
Visibility: database.ChatMessageVisibilityBoth,
InputTokens: sql.NullInt64{},
OutputTokens: sql.NullInt64{},
+48 -10
View File
@@ -91,15 +91,14 @@ func ConvertMessagesWithFiles(
continue
}
role := codersdk.ChatMessageRole(msg.Role)
parts, err := ParseContent(role, msg.Content)
parts, err := ParseContent(msg)
if err != nil {
return nil, err
}
parsed[i] = parsedMessage{role: role, parts: parts}
parsed[i] = parsedMessage{role: codersdk.ChatMessageRole(msg.Role), parts: parts}
// Collect file IDs from user messages for resolution.
if resolver != nil && msg.Role == string(codersdk.ChatMessageRoleUser) {
if resolver != nil && msg.Role == database.ChatMessageRoleUser {
for _, part := range parts {
if part.Type == codersdk.ChatMessagePartTypeFile && part.FileID.Valid {
if _, seen := seenFileIDs[part.FileID.UUID]; !seen {
@@ -262,16 +261,45 @@ func AppendUser(prompt []fantasy.Message, instruction string) []fantasy.Message
return out
}
const (
// ContentVersionV0 is the legacy content format. Parsing uses
// role-aware heuristics to distinguish fantasy envelope format
// from SDK parts.
ContentVersionV0 int16 = 0
// ContentVersionV1 stores content as []codersdk.ChatMessagePart
// JSON for all roles.
ContentVersionV1 int16 = 1
// CurrentContentVersion is the version used for new inserts.
CurrentContentVersion = ContentVersionV1
)
// ParseContent decodes persisted chat message content blocks into
// SDK parts. Role-aware: system messages are JSON strings,
// assistant and user messages use a structural heuristic
// (isFantasyEnvelopeFormat) to distinguish legacy fantasy envelope
// from SDK parts, and tool messages use try/fallback.
func ParseContent(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
if !raw.Valid || len(raw.RawMessage) == 0 {
// SDK parts. Dispatches on content version: version 0 (legacy) uses
// a role-aware heuristic to distinguish fantasy envelope format
// from SDK parts, version 1 (current) unmarshals SDK-format
// []ChatMessagePart directly.
func ParseContent(msg database.ChatMessage) ([]codersdk.ChatMessagePart, error) {
if !msg.Content.Valid || len(msg.Content.RawMessage) == 0 {
return nil, nil
}
role := codersdk.ChatMessageRole(msg.Role)
switch msg.ContentVersion {
case ContentVersionV0:
return parseLegacyContent(role, msg.Content)
case ContentVersionV1:
return parseContentV1(role, msg.Content)
default:
return nil, xerrors.Errorf("unsupported content version %d", msg.ContentVersion)
}
}
// parseLegacyContent handles content version 0, where the format
// varies by role and era. Uses structural heuristics to distinguish
// fantasy envelope format from SDK parts.
func parseLegacyContent(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
switch role {
case codersdk.ChatMessageRoleSystem:
return parseSystemRole(raw)
@@ -286,6 +314,16 @@ func ParseContent(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) ([]c
}
}
// parseContentV1 handles content version 1. Content is a JSON
// array of ChatMessagePart structs.
func parseContentV1(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(raw.RawMessage, &parts); err != nil {
return nil, xerrors.Errorf("parse %s content: %w", role, err)
}
return parts, nil
}
// parseSystemRole decodes a system message (JSON string) into a
// single text part.
func parseSystemRole(raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
+104 -48
View File
@@ -20,6 +20,25 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// testMsg builds a database.ChatMessage for ParseContent tests.
// ContentVersion defaults to 0 (legacy), which exercises the
// heuristic detection path.
func testMsg(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) database.ChatMessage {
return database.ChatMessage{
Role: database.ChatMessageRole(role),
Content: raw,
}
}
// testMsgV1 builds a database.ChatMessage with ContentVersion 1.
func testMsgV1(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) database.ChatMessage {
return database.ChatMessage{
Role: database.ChatMessageRole(role),
Content: raw,
ContentVersion: chatprompt.CurrentContentVersion,
}
}
func TestConvertMessages_NormalizesAssistantToolCallInput(t *testing.T) {
t.Parallel()
@@ -75,12 +94,12 @@ func TestConvertMessages_NormalizesAssistantToolCallInput(t *testing.T) {
prompt, err := chatprompt.ConvertMessages([]database.ChatMessage{
{
Role: string(fantasy.MessageRoleAssistant),
Role: database.ChatMessageRoleAssistant,
Visibility: database.ChatMessageVisibilityBoth,
Content: assistantContent,
},
{
Role: string(fantasy.MessageRoleTool),
Role: database.ChatMessageRoleTool,
Visibility: database.ChatMessageVisibilityBoth,
Content: toolContent,
},
@@ -135,7 +154,7 @@ func TestConvertMessagesWithFiles_ResolvesFileData(t *testing.T) {
context.Background(),
[]database.ChatMessage{
{
Role: string(fantasy.MessageRoleUser),
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: rawContent, Valid: true},
},
@@ -192,7 +211,7 @@ func TestConvertMessagesWithFiles_BackwardCompat(t *testing.T) {
context.Background(),
[]database.ChatMessage{
{
Role: string(fantasy.MessageRoleUser),
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: rawContent, Valid: true},
},
@@ -279,12 +298,12 @@ func TestInjectMissingToolResults_SkipsProviderExecuted(t *testing.T) {
prompt, err := chatprompt.ConvertMessages([]database.ChatMessage{
{
Role: "assistant",
Role: database.ChatMessageRoleAssistant,
Visibility: database.ChatMessageVisibilityBoth,
Content: assistantContent,
},
{
Role: "tool",
Role: database.ChatMessageRoleTool,
Visibility: database.ChatMessageVisibilityBoth,
Content: localResult,
},
@@ -381,16 +400,16 @@ func TestInjectMissingToolUses_DropsProviderExecutedOrphans(t *testing.T) {
prompt, err := chatprompt.ConvertMessages([]database.ChatMessage{
// Step 1
{Role: "assistant", Visibility: database.ChatMessageVisibilityBoth, Content: step1Assistant},
{Role: "tool", Visibility: database.ChatMessageVisibilityBoth, Content: resultA},
{Role: "tool", Visibility: database.ChatMessageVisibilityBoth, Content: resultB},
{Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth, Content: step1Assistant},
{Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: resultA},
{Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: resultB},
// Step 2
{Role: "assistant", Visibility: database.ChatMessageVisibilityBoth, Content: step2Assistant},
{Role: "tool", Visibility: database.ChatMessageVisibilityBoth, Content: resultC},
{Role: "tool", Visibility: database.ChatMessageVisibilityBoth, Content: resultD},
{Role: "tool", Visibility: database.ChatMessageVisibilityBoth, Content: resultE},
{Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth, Content: step2Assistant},
{Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: resultC},
{Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: resultD},
{Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: resultE},
// User follow-up
{Role: "user", Visibility: database.ChatMessageVisibilityBoth, Content: mustMarshalContent(t, []fantasy.Content{
{Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, Content: mustMarshalContent(t, []fantasy.Content{
fantasy.TextContent{Text: "?"},
})},
})
@@ -468,10 +487,10 @@ func TestInjectMissingToolUses_DropsOnlyProviderExecutedMessage(t *testing.T) {
)
prompt, err := chatprompt.ConvertMessages([]database.ChatMessage{
{Role: "assistant", Visibility: database.ChatMessageVisibilityBoth, Content: assistantContent},
{Role: "tool", Visibility: database.ChatMessageVisibilityBoth, Content: localResult},
{Role: "assistant", Visibility: database.ChatMessageVisibilityBoth, Content: assistant2Content},
{Role: "tool", Visibility: database.ChatMessageVisibilityBoth, Content: peResult},
{Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth, Content: assistantContent},
{Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: localResult},
{Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth, Content: assistant2Content},
{Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: peResult},
})
require.NoError(t, err)
@@ -513,8 +532,8 @@ func TestProviderExecutedResultInAssistantContent(t *testing.T) {
})
prompt, err := chatprompt.ConvertMessages([]database.ChatMessage{
{Role: "assistant", Visibility: database.ChatMessageVisibilityBoth, Content: assistantContent},
{Role: "user", Visibility: database.ChatMessageVisibilityBoth, Content: mustMarshalContent(t, []fantasy.Content{
{Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth, Content: assistantContent},
{Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, Content: mustMarshalContent(t, []fantasy.Content{
fantasy.TextContent{Text: "Thanks!"},
})},
})
@@ -586,10 +605,10 @@ func TestProviderExecutedResult_LegacyToolRow(t *testing.T) {
)
prompt, err := chatprompt.ConvertMessages([]database.ChatMessage{
{Role: "assistant", Visibility: database.ChatMessageVisibilityBoth, Content: assistantContent},
{Role: "tool", Visibility: database.ChatMessageVisibilityBoth, Content: peResult},
{Role: "tool", Visibility: database.ChatMessageVisibilityBoth, Content: execResult},
{Role: "user", Visibility: database.ChatMessageVisibilityBoth, Content: mustMarshalContent(t, []fantasy.Content{
{Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth, Content: assistantContent},
{Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: peResult},
{Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: execResult},
{Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, Content: mustMarshalContent(t, []fantasy.Content{
fantasy.TextContent{Text: "next"},
})},
})
@@ -921,13 +940,50 @@ func TestParseContent_BackwardCompat(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
parts, err := chatprompt.ParseContent(tc.role, tc.raw)
parts, err := chatprompt.ParseContent(testMsg(tc.role, tc.raw))
require.NoError(t, err)
tc.check(t, parts)
})
}
}
func TestParseContent_V1(t *testing.T) {
t.Parallel()
t.Run("system", func(t *testing.T) {
t.Parallel()
raw, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("You are helpful."),
})
require.NoError(t, err)
parts, err := chatprompt.ParseContent(testMsgV1(codersdk.ChatMessageRoleSystem, raw))
require.NoError(t, err)
require.Len(t, parts, 1)
assert.Equal(t, codersdk.ChatMessagePartTypeText, parts[0].Type)
assert.Equal(t, "You are helpful.", parts[0].Text)
})
t.Run("system_bare_string_errors", func(t *testing.T) {
t.Parallel()
// A bare JSON string is not valid V1 content.
_, err := chatprompt.ParseContent(testMsgV1(
codersdk.ChatMessageRoleSystem,
nullRaw(json.RawMessage(`"You are helpful."`)),
))
require.Error(t, err)
})
t.Run("unknown_version_errors", func(t *testing.T) {
t.Parallel()
msg := testMsgV1(codersdk.ChatMessageRoleUser, nullRaw(json.RawMessage(`[{"type":"text","text":"hi"}]`)))
msg.ContentVersion = 99
_, err := chatprompt.ParseContent(msg)
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported content version")
})
}
// TestProviderMetadataRoundTrip verifies that Anthropic cache
// control hints survive the full path: legacy fantasy DB row →
// ParseContent → SDK part (ProviderMetadata) → partsToMessageParts
@@ -948,7 +1004,7 @@ func TestProviderMetadataRoundTrip(t *testing.T) {
require.NoError(t, err)
// Step 1: ParseContent preserves metadata on the SDK part.
parts, err := chatprompt.ParseContent(codersdk.ChatMessageRoleAssistant, legacyContent)
parts, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleAssistant, legacyContent))
require.NoError(t, err)
require.Len(t, parts, 1)
require.NotNil(t, parts[0].ProviderMetadata,
@@ -959,7 +1015,7 @@ func TestProviderMetadataRoundTrip(t *testing.T) {
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: "assistant",
Role: database.ChatMessageRoleAssistant,
Visibility: database.ChatMessageVisibilityBoth,
Content: legacyContent,
}},
@@ -994,7 +1050,7 @@ func TestFileReferencePreservation(t *testing.T) {
require.NoError(t, err)
// Storage round-trip: all fields intact.
parts, err := chatprompt.ParseContent(codersdk.ChatMessageRoleUser, raw)
parts, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleUser, raw))
require.NoError(t, err)
require.Len(t, parts, 1)
assert.Equal(t, codersdk.ChatMessagePartTypeFileReference, parts[0].Type)
@@ -1007,7 +1063,7 @@ func TestFileReferencePreservation(t *testing.T) {
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: "user",
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: raw,
}},
@@ -1052,7 +1108,7 @@ func TestAssistantWriteRoundTrip(t *testing.T) {
// Read back via ParseContent (takes the new SDK path, not
// the legacy fallback, because the stored format is flat).
parts, err := chatprompt.ParseContent(codersdk.ChatMessageRoleAssistant, raw)
parts, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleAssistant, raw))
require.NoError(t, err)
require.Len(t, parts, 1)
assert.Equal(t, "response with cache hints", parts[0].Text)
@@ -1062,7 +1118,7 @@ func TestAssistantWriteRoundTrip(t *testing.T) {
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: "assistant",
Role: database.ChatMessageRoleAssistant,
Visibility: database.ChatMessageVisibilityBoth,
Content: raw,
}},
@@ -1153,12 +1209,12 @@ func TestMixedFormatConversation(t *testing.T) {
require.NoError(t, err)
messages := []database.ChatMessage{
{Role: "system", Visibility: database.ChatMessageVisibilityModel, Content: pqtype.NullRawMessage{RawMessage: systemRaw, Valid: true}},
{Role: "user", Visibility: database.ChatMessageVisibilityBoth, Content: pqtype.NullRawMessage{RawMessage: oldUserRaw, Valid: true}},
{Role: "assistant", Visibility: database.ChatMessageVisibilityBoth, Content: oldAssistantRaw},
{Role: "tool", Visibility: database.ChatMessageVisibilityBoth, Content: oldToolRaw},
{Role: "user", Visibility: database.ChatMessageVisibilityBoth, Content: newUserRaw},
{Role: "assistant", Visibility: database.ChatMessageVisibilityBoth, Content: newAssistantRaw},
{Role: database.ChatMessageRoleSystem, Visibility: database.ChatMessageVisibilityModel, Content: pqtype.NullRawMessage{RawMessage: systemRaw, Valid: true}},
{Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, Content: pqtype.NullRawMessage{RawMessage: oldUserRaw, Valid: true}},
{Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth, Content: oldAssistantRaw},
{Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: oldToolRaw},
{Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, Content: newUserRaw},
{Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth, Content: newAssistantRaw},
}
prompt, err := chatprompt.ConvertMessagesWithFiles(
@@ -1258,10 +1314,10 @@ func TestQueuedMessageRoundTrip(t *testing.T) {
// Step 2: PromoteQueued copies the raw bytes into
// chat_messages. ParseContent must handle them identically.
promoted, err := chatprompt.ParseContent(codersdk.ChatMessageRoleUser, pqtype.NullRawMessage{
promoted, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleUser, pqtype.NullRawMessage{
RawMessage: raw.RawMessage,
Valid: true,
})
}))
require.NoError(t, err)
require.Len(t, promoted, 2)
assert.Equal(t, codersdk.ChatMessagePartTypeText, promoted[0].Type)
@@ -1277,7 +1333,7 @@ func TestQueuedMessageRoundTrip(t *testing.T) {
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: "user",
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: raw.RawMessage, Valid: true},
}},
@@ -1303,50 +1359,50 @@ func TestParseContent_ErrorPaths(t *testing.T) {
t.Run("null_content_returns_nil", func(t *testing.T) {
t.Parallel()
parts, err := chatprompt.ParseContent(codersdk.ChatMessageRoleUser, pqtype.NullRawMessage{})
parts, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleUser, pqtype.NullRawMessage{}))
require.NoError(t, err)
assert.Nil(t, parts)
})
t.Run("empty_content_returns_nil", func(t *testing.T) {
t.Parallel()
parts, err := chatprompt.ParseContent(codersdk.ChatMessageRoleAssistant, pqtype.NullRawMessage{
parts, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleAssistant, pqtype.NullRawMessage{
RawMessage: []byte{},
Valid: true,
})
}))
require.NoError(t, err)
assert.Nil(t, parts)
})
t.Run("unknown_role", func(t *testing.T) {
t.Parallel()
_, err := chatprompt.ParseContent(codersdk.ChatMessageRole("banana"), nullRaw(json.RawMessage(`"hello"`)))
_, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRole("banana"), nullRaw(json.RawMessage(`"hello"`))))
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported chat message role")
})
t.Run("system/malformed_json", func(t *testing.T) {
t.Parallel()
_, err := chatprompt.ParseContent(codersdk.ChatMessageRoleSystem, nullRaw(json.RawMessage(`not json`)))
_, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleSystem, nullRaw(json.RawMessage(`not json`))))
require.Error(t, err)
assert.Contains(t, err.Error(), "parse system content")
})
t.Run("user/malformed_json", func(t *testing.T) {
t.Parallel()
_, err := chatprompt.ParseContent(codersdk.ChatMessageRoleUser, nullRaw(json.RawMessage(`{not json`)))
_, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleUser, nullRaw(json.RawMessage(`{not json`))))
require.Error(t, err)
})
t.Run("assistant/malformed_json", func(t *testing.T) {
t.Parallel()
_, err := chatprompt.ParseContent(codersdk.ChatMessageRoleAssistant, nullRaw(json.RawMessage(`{not json`)))
_, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleAssistant, nullRaw(json.RawMessage(`{not json`))))
require.Error(t, err)
})
t.Run("tool/malformed_json", func(t *testing.T) {
t.Parallel()
_, err := chatprompt.ParseContent(codersdk.ChatMessageRoleTool, nullRaw(json.RawMessage(`{not json`)))
_, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleTool, nullRaw(json.RawMessage(`{not json`))))
require.Error(t, err)
})
}
+3 -5
View File
@@ -159,14 +159,12 @@ func titleInput(
}
switch message.Role {
case string(codersdk.ChatMessageRoleAssistant), string(codersdk.ChatMessageRoleTool):
case database.ChatMessageRoleAssistant, database.ChatMessageRoleTool:
return "", false
case string(codersdk.ChatMessageRoleUser):
case database.ChatMessageRoleUser:
userCount++
if firstUserText == "" {
parsed, err := chatprompt.ParseContent(
codersdk.ChatMessageRoleUser, message.Content,
)
parsed, err := chatprompt.ParseContent(message)
if err != nil {
return "", false
}
+2 -2
View File
@@ -482,12 +482,12 @@ func latestSubagentAssistantMessage(
for i := len(messages) - 1; i >= 0; i-- {
message := messages[i]
if message.Role != string(codersdk.ChatMessageRoleAssistant) ||
if message.Role != database.ChatMessageRoleAssistant ||
message.Visibility == database.ChatMessageVisibilityModel {
continue
}
content, parseErr := chatprompt.ParseContent(codersdk.ChatMessageRole(message.Role), message.Content)
content, parseErr := chatprompt.ParseContent(message)
if parseErr != nil {
continue
}