Compare commits

...

2 Commits

Author SHA1 Message Date
Kyle Carberry 2f3fc8a13f refactor(coderd): store all message types as SDK parts, remove reasoning titles
All message roles (user, assistant, tool) now persist as
[]codersdk.ChatMessagePart JSON in the database instead of the fantasy
envelope format. Fantasy types are only used at LLM dispatch time.

Key changes:
- MarshalParts replaces MarshalContent/MarshalUserContent/marshalContentBlock
- ParseContent returns []codersdk.ChatMessagePart (was []fantasy.Content)
- chatloop accumulates []codersdk.ChatMessagePart (was []fantasy.Content)
- db2sdk delegates to chatprompt.ParseContent for all roles
- ProviderMetadata stored as opaque JSON on ChatMessagePart for round-trip
- Reasoning title extraction removed (reasoningSummaryTitle, etc.)
- Legacy fantasy envelope format still readable for backward compat

Removes ~500 lines of fantasy envelope marshaling, title injection,
file ID injection, and duplicate content-to-part conversion code.
2026-03-10 11:53:59 +00:00
Kyle Carberry be755176c6 fix: store file-reference parts natively in chat message DB content
User messages are now stored as []codersdk.ChatMessagePart JSON
directly in the DB content column, instead of the fantasy
{"type": ..., "data": {...}} envelope format. This means:

- File-reference parts keep their structured metadata (file_name,
  start_line, end_line, content) in storage, so the API returns
  them as proper file-reference ChatMessagePart types that the
  frontend renders as styled chips.

- File parts store file_id and media_type directly on the part
  instead of using injectFileID/ExtractFileID to hack them into
  the fantasy envelope.

- MarshalUserContent is now just json.Marshal(parts).

- The chatd option structs carry []codersdk.ChatMessagePart
  directly, with no separate fileIDs/mediaTypes maps.

The read path detects the storage format: new SDK format has
fields at the top level, legacy fantasy format has a "data"
object wrapper. Both are handled transparently.

Assistant and tool messages continue using the fantasy envelope
format since they originate from the LLM via fantasy.Content.
2026-03-09 02:59:41 +00:00
14 changed files with 762 additions and 1081 deletions
+60 -72
View File
@@ -163,17 +163,14 @@ var (
// CreateOptions controls chat creation in the shared chat mutation path.
type CreateOptions struct {
OwnerID uuid.UUID
WorkspaceID uuid.NullUUID
ParentChatID uuid.NullUUID
RootChatID uuid.NullUUID
Title string
ModelConfigID uuid.UUID
SystemPrompt string
InitialUserContent []fantasy.Content
// ContentFileIDs maps content block indices to their chat_files IDs
// so the file_id can be preserved in the stored message JSON.
ContentFileIDs map[int]uuid.UUID
OwnerID uuid.UUID
WorkspaceID uuid.NullUUID
ParentChatID uuid.NullUUID
RootChatID uuid.NullUUID
Title string
ModelConfigID uuid.UUID
SystemPrompt string
Content []codersdk.ChatMessagePart
}
// SendMessageBusyBehavior controls what happens when a chat is already active.
@@ -191,11 +188,10 @@ const (
// SendMessageOptions controls user message insertion with busy-state behavior.
type SendMessageOptions struct {
ChatID uuid.UUID
Content []fantasy.Content
ContentFileIDs map[int]uuid.UUID
ModelConfigID *uuid.UUID
BusyBehavior SendMessageBusyBehavior
ChatID uuid.UUID
Content []codersdk.ChatMessagePart
ModelConfigID *uuid.UUID
BusyBehavior SendMessageBusyBehavior
}
// SendMessageResult contains the outcome of user message processing.
@@ -210,8 +206,7 @@ type SendMessageResult struct {
type EditMessageOptions struct {
ChatID uuid.UUID
EditedMessageID int64
Content []fantasy.Content
ContentFileIDs map[int]uuid.UUID
Content []codersdk.ChatMessagePart
}
// EditMessageResult contains the updated user message and chat status.
@@ -241,7 +236,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
if strings.TrimSpace(opts.Title) == "" {
return database.Chat{}, xerrors.New("title is required")
}
if len(opts.InitialUserContent) == 0 {
if len(opts.Content) == 0 {
return database.Chat{}, xerrors.New("initial user content is required")
}
@@ -291,8 +286,8 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
}
}
userContent, err := chatprompt.MarshalContent(opts.InitialUserContent, opts.ContentFileIDs)
if err != nil {
userContent, err := chatprompt.MarshalParts(opts.Content)
if err != nil {
return xerrors.Errorf("marshal initial user content: %w", err)
}
_, err = insertChatMessageWithStore(ctx, tx, database.InsertChatMessageParams{
@@ -358,11 +353,10 @@ func (p *Server) SendMessage(
return SendMessageResult{}, xerrors.Errorf("invalid busy behavior %q", opts.BusyBehavior)
}
content, err := chatprompt.MarshalContent(opts.Content, opts.ContentFileIDs)
if err != nil {
return SendMessageResult{}, xerrors.Errorf("marshal message content: %w", err)
}
content, err := chatprompt.MarshalParts(opts.Content)
if err != nil {
return SendMessageResult{}, xerrors.Errorf("marshal message content: %w", err)
}
var (
result SendMessageResult
queuedMessagesSDK []codersdk.ChatQueuedMessage
@@ -491,11 +485,10 @@ func (p *Server) EditMessage(
return EditMessageResult{}, xerrors.New("content is required")
}
content, err := chatprompt.MarshalContent(opts.Content, opts.ContentFileIDs)
if err != nil {
return EditMessageResult{}, xerrors.Errorf("marshal message content: %w", err)
}
content, err := chatprompt.MarshalParts(opts.Content)
if err != nil {
return EditMessageResult{}, xerrors.Errorf("marshal message content: %w", err)
}
var result EditMessageResult
txErr := p.db.InTx(func(tx database.Store) error {
_, err := tx.GetChatByIDForUpdate(ctx, opts.ChatID)
@@ -2226,44 +2219,39 @@ func (p *Server) runChat(
return chatloop.ErrInterrupted
}
// Split the step content into assistant blocks and tool
// result blocks so they can be stored as separate messages
// with the appropriate roles.
var assistantBlocks []fantasy.Content
var toolResults []fantasy.ToolResultContent
for _, block := range step.Content {
if tr, ok := fantasy.AsContentType[fantasy.ToolResultContent](block); ok {
toolResults = append(toolResults, tr)
continue
}
if trPtr, ok := fantasy.AsContentType[*fantasy.ToolResultContent](block); ok && trPtr != nil {
toolResults = append(toolResults, *trPtr)
continue
}
assistantBlocks = append(assistantBlocks, block)
}
var insertedMessages []database.ChatMessage
err := p.db.InTx(func(tx database.Store) error {
// Verify this worker still owns the chat before
// inserting messages. This closes the race where
// EditMessage truncates history and clears worker_id
// while persistInterruptedStep (which uses an
// uncancelable context) is still running.
lockedChat, lockErr := tx.GetChatByIDForUpdate(persistCtx, chat.ID)
if lockErr != nil {
return xerrors.Errorf("lock chat for persist: %w", lockErr)
}
if !lockedChat.WorkerID.Valid || lockedChat.WorkerID.UUID != p.workerID {
return chatloop.ErrInterrupted
// Split the step content into assistant blocks and tool
// result blocks so they can be stored as separate messages
// with the appropriate roles.
var assistantParts []codersdk.ChatMessagePart
var toolResultParts []codersdk.ChatMessagePart
for _, part := range step.Content {
if part.Type == codersdk.ChatMessagePartTypeToolResult {
toolResultParts = append(toolResultParts, part)
continue
}
assistantParts = append(assistantParts, part)
}
if len(assistantBlocks) > 0 {
assistantContent, marshalErr := chatprompt.MarshalContent(assistantBlocks, nil)
if marshalErr != nil {
return marshalErr
var insertedMessages []database.ChatMessage
err := p.db.InTx(func(tx database.Store) error {
// Verify this worker still owns the chat before
// inserting messages. This closes the race where
// EditMessage truncates history and clears worker_id
// while persistInterruptedStep (which uses an
// uncancelable context) is still running.
lockedChat, lockErr := tx.GetChatByIDForUpdate(persistCtx, chat.ID)
if lockErr != nil {
return xerrors.Errorf("lock chat for persist: %w", lockErr)
}
if !lockedChat.WorkerID.Valid || lockedChat.WorkerID.UUID != p.workerID {
return chatloop.ErrInterrupted
}
if len(assistantParts) > 0 {
assistantContent, marshalErr := chatprompt.MarshalParts(assistantParts)
if marshalErr != nil {
return marshalErr
}
hasUsage := step.Usage != (fantasy.Usage{})
assistantMessage, insertErr := tx.InsertChatMessage(persistCtx, database.InsertChatMessageParams{
ChatID: chat.ID,
@@ -2292,9 +2280,9 @@ func (p *Server) runChat(
insertedMessages = append(insertedMessages, assistantMessage)
}
for _, tr := range toolResults {
resultContent, marshalErr := chatprompt.MarshalToolResultContent(tr)
if marshalErr != nil {
for _, tr := range toolResultParts {
resultContent, marshalErr := chatprompt.MarshalParts([]codersdk.ChatMessagePart{tr})
if marshalErr != nil {
return marshalErr
}
@@ -2544,13 +2532,13 @@ func (p *Server) persistChatContextSummary(
return xerrors.Errorf("encode summary tool args: %w", err)
}
assistantContent, err := chatprompt.MarshalContent([]fantasy.Content{
fantasy.ToolCallContent{
assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
chatprompt.PartFromContent(fantasy.ToolCallContent{
ToolCallID: toolCallID,
ToolName: "chat_summarized",
Input: string(args),
},
}, nil)
}),
})
if err != nil {
return xerrors.Errorf("encode summary tool call: %w", err)
}
+21 -22
View File
@@ -12,7 +12,6 @@ import (
"testing"
"time"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
@@ -48,7 +47,7 @@ func TestInterruptChatBroadcastsStatusAcrossInstances(t *testing.T) {
OwnerID: user.ID,
Title: "interrupt-me",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
@@ -251,7 +250,7 @@ func TestInterruptChatClearsWorkerInDatabase(t *testing.T) {
OwnerID: user.ID,
Title: "db-transition",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
@@ -287,7 +286,7 @@ func TestUpdateChatHeartbeatRequiresOwnership(t *testing.T) {
OwnerID: user.ID,
Title: "heartbeat-ownership",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
@@ -329,7 +328,7 @@ func TestSendMessageQueueBehaviorQueuesWhenBusy(t *testing.T) {
OwnerID: user.ID,
Title: "queue-when-busy",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
@@ -345,7 +344,7 @@ func TestSendMessageQueueBehaviorQueuesWhenBusy(t *testing.T) {
result, err := replica.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
Content: []fantasy.Content{fantasy.TextContent{Text: "queued"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "queued"}},
BusyBehavior: chatd.SendMessageBusyBehaviorQueue,
})
require.NoError(t, err)
@@ -380,7 +379,7 @@ func TestSendMessageInterruptBehaviorQueuesAndInterruptsWhenBusy(t *testing.T) {
OwnerID: user.ID,
Title: "interrupt-when-busy",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
@@ -395,7 +394,7 @@ func TestSendMessageInterruptBehaviorQueuesAndInterruptsWhenBusy(t *testing.T) {
result, err := replica.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
Content: []fantasy.Content{fantasy.TextContent{Text: "interrupt"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "interrupt"}},
BusyBehavior: chatd.SendMessageBusyBehaviorInterrupt,
})
require.NoError(t, err)
@@ -439,7 +438,7 @@ func TestEditMessageUpdatesAndTruncatesAndClearsQueue(t *testing.T) {
OwnerID: user.ID,
Title: "edit-message",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "original"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "original"}},
})
require.NoError(t, err)
@@ -453,13 +452,13 @@ func TestEditMessageUpdatesAndTruncatesAndClearsQueue(t *testing.T) {
_, err = replica.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
Content: []fantasy.Content{fantasy.TextContent{Text: "follow-up"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "follow-up"}},
BusyBehavior: chatd.SendMessageBusyBehaviorInterrupt,
})
require.NoError(t, err)
_, err = replica.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
Content: []fantasy.Content{fantasy.TextContent{Text: "another"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "another"}},
BusyBehavior: chatd.SendMessageBusyBehaviorInterrupt,
})
require.NoError(t, err)
@@ -482,7 +481,7 @@ func TestEditMessageUpdatesAndTruncatesAndClearsQueue(t *testing.T) {
editResult, err := replica.EditMessage(ctx, chatd.EditMessageOptions{
ChatID: chat.ID,
EditedMessageID: editedMessageID,
Content: []fantasy.Content{fantasy.TextContent{Text: "edited"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "edited"}},
})
require.NoError(t, err)
require.Equal(t, editedMessageID, editResult.Message.ID)
@@ -527,14 +526,14 @@ func TestEditMessageRejectsMissingMessage(t *testing.T) {
OwnerID: user.ID,
Title: "missing-edited-message",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
_, err = replica.EditMessage(ctx, chatd.EditMessageOptions{
ChatID: chat.ID,
EditedMessageID: 999999,
Content: []fantasy.Content{fantasy.TextContent{Text: "edited"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "edited"}},
})
require.Error(t, err)
require.True(t, errors.Is(err, chatd.ErrEditedMessageNotFound))
@@ -553,7 +552,7 @@ func TestEditMessageRejectsNonUserMessage(t *testing.T) {
OwnerID: user.ID,
Title: "non-user-edited-message",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
@@ -580,7 +579,7 @@ func TestEditMessageRejectsNonUserMessage(t *testing.T) {
_, err = replica.EditMessage(ctx, chatd.EditMessageOptions{
ChatID: chat.ID,
EditedMessageID: assistantMessage.ID,
Content: []fantasy.Content{fantasy.TextContent{Text: "edited"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "edited"}},
})
require.Error(t, err)
require.True(t, errors.Is(err, chatd.ErrEditedMessageNotUser))
@@ -827,7 +826,7 @@ func TestSubscribeSnapshotIncludesStatusEvent(t *testing.T) {
OwnerID: user.ID,
Title: "status-snapshot",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
@@ -856,7 +855,7 @@ func TestSubscribeNoPubsubNoDuplicateMessageParts(t *testing.T) {
OwnerID: user.ID,
Title: "no-dup-parts",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
@@ -896,7 +895,7 @@ func TestSubscribeAfterMessageID(t *testing.T) {
OwnerID: user.ID,
Title: "after-id-test",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "first"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "first"}},
})
require.NoError(t, err)
@@ -1425,7 +1424,7 @@ func TestInterruptChatDoesNotSendWebPushNotification(t *testing.T) {
OwnerID: user.ID,
Title: "interrupt-no-push",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
@@ -1530,7 +1529,7 @@ func TestSuccessfulChatSendsWebPushWithNavigationData(t *testing.T) {
OwnerID: user.ID,
Title: "push-nav-test",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
@@ -1608,7 +1607,7 @@ func TestCloseDuringShutdownContextCanceledShouldRetryOnNewReplica(t *testing.T)
OwnerID: user.ID,
Title: "shutdown-retry",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}},
})
require.NoError(t, err)
+144 -129
View File
@@ -38,7 +38,7 @@ var ErrInterrupted = xerrors.New("chat interrupted")
// persistence layer is responsible for splitting these into
// separate database messages by role.
type PersistedStep struct {
Content []fantasy.Content
Content []codersdk.ChatMessagePart
Usage fantasy.Usage
ContextLimit sql.NullInt64
}
@@ -85,7 +85,7 @@ type RunOptions struct {
// step. Since we own the stream consumer, all content is tracked
// directly here — no shadow draft state needed.
type stepResult struct {
content []fantasy.Content
content []codersdk.ChatMessagePart
usage fantasy.Usage
providerMetadata fantasy.ProviderMetadata
finishReason fantasy.FinishReason
@@ -96,79 +96,61 @@ type stepResult struct {
// toResponseMessages converts step content into messages suitable
// for appending to the conversation. Mirrors fantasy's
// toResponseMessages logic.
func (r stepResult) toResponseMessages() []fantasy.Message {
var assistantParts []fantasy.MessagePart
var toolParts []fantasy.MessagePart
for _, c := range r.content {
switch c.GetType() {
case fantasy.ContentTypeText:
text, ok := fantasy.AsContentType[fantasy.TextContent](c)
if !ok {
continue
}
assistantParts = append(assistantParts, fantasy.TextPart{
Text: text.Text,
ProviderOptions: fantasy.ProviderOptions(text.ProviderMetadata),
func (r *stepResult) toResponseMessages() []fantasy.Message {
parts := make([]fantasy.MessagePart, 0, len(r.content))
for _, part := range r.content {
switch part.Type {
case codersdk.ChatMessagePartTypeText:
parts = append(parts, fantasy.TextPart{
Text: part.Text,
ProviderOptions: providerMetadataToOptions(part.ProviderMetadata),
})
case fantasy.ContentTypeReasoning:
reasoning, ok := fantasy.AsContentType[fantasy.ReasoningContent](c)
if !ok {
continue
}
assistantParts = append(assistantParts, fantasy.ReasoningPart{
Text: reasoning.Text,
ProviderOptions: fantasy.ProviderOptions(reasoning.ProviderMetadata),
case codersdk.ChatMessagePartTypeReasoning:
parts = append(parts, fantasy.ReasoningPart{
Text: part.Text,
ProviderOptions: providerMetadataToOptions(part.ProviderMetadata),
})
case fantasy.ContentTypeToolCall:
toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](c)
if !ok {
continue
}
assistantParts = append(assistantParts, fantasy.ToolCallPart{
ToolCallID: toolCall.ToolCallID,
ToolName: toolCall.ToolName,
Input: toolCall.Input,
ProviderExecuted: toolCall.ProviderExecuted,
ProviderOptions: fantasy.ProviderOptions(toolCall.ProviderMetadata),
case codersdk.ChatMessagePartTypeToolCall:
parts = append(parts, fantasy.ToolCallPart{
ToolCallID: part.ToolCallID,
ToolName: part.ToolName,
Input: string(part.Args),
ProviderExecuted: part.ProviderExecuted,
ProviderOptions: providerMetadataToOptions(part.ProviderMetadata),
})
case fantasy.ContentTypeFile:
file, ok := fantasy.AsContentType[fantasy.FileContent](c)
if !ok {
continue
}
assistantParts = append(assistantParts, fantasy.FilePart{
Data: file.Data,
MediaType: file.MediaType,
ProviderOptions: fantasy.ProviderOptions(file.ProviderMetadata),
case codersdk.ChatMessagePartTypeFile:
parts = append(parts, fantasy.FilePart{
Data: part.Data,
MediaType: part.MediaType,
ProviderOptions: providerMetadataToOptions(part.ProviderMetadata),
})
case fantasy.ContentTypeSource:
case codersdk.ChatMessagePartTypeToolResult:
p := fantasy.ToolResultPart{
ToolCallID: part.ToolCallID,
ProviderOptions: providerMetadataToOptions(part.ProviderMetadata),
}
if part.IsError {
errMsg := string(part.Result)
p.Output = fantasy.ToolResultOutputContentError{Error: xerrors.New(errMsg)}
} else if len(part.Result) > 0 {
p.Output = fantasy.ToolResultOutputContentText{Text: string(part.Result)}
}
parts = append(parts, p)
case codersdk.ChatMessagePartTypeSource:
// Sources are metadata about references; they don't
// need to be included in conversation messages.
continue
case fantasy.ContentTypeToolResult:
result, ok := fantasy.AsContentType[fantasy.ToolResultContent](c)
if !ok {
continue
}
toolParts = append(toolParts, fantasy.ToolResultPart{
ToolCallID: result.ToolCallID,
Output: result.Result,
ProviderOptions: fantasy.ProviderOptions(result.ProviderMetadata),
})
default:
continue
}
}
var messages []fantasy.Message
if len(assistantParts) > 0 {
messages := make([]fantasy.Message, 0, 2)
if assistantParts := filterAssistantParts(parts); len(assistantParts) > 0 {
messages = append(messages, fantasy.Message{
Role: fantasy.MessageRoleAssistant,
Content: assistantParts,
})
}
if len(toolParts) > 0 {
if toolParts := filterToolParts(parts); len(toolParts) > 0 {
messages = append(messages, fantasy.Message{
Role: fantasy.MessageRoleTool,
Content: toolParts,
@@ -177,6 +159,57 @@ func (r stepResult) toResponseMessages() []fantasy.Message {
return messages
}
// filterAssistantParts returns only the message parts that belong
// in an assistant message (text, reasoning, tool calls, files).
func filterAssistantParts(parts []fantasy.MessagePart) []fantasy.MessagePart {
var out []fantasy.MessagePart
for _, p := range parts {
switch p.(type) {
case fantasy.TextPart, fantasy.ReasoningPart,
fantasy.ToolCallPart, fantasy.FilePart:
out = append(out, p)
}
}
return out
}
// filterToolParts returns only the tool-result parts.
func filterToolParts(parts []fantasy.MessagePart) []fantasy.MessagePart {
var out []fantasy.MessagePart
for _, p := range parts {
if _, ok := p.(fantasy.ToolResultPart); ok {
out = append(out, p)
}
}
return out
}
// marshalProviderMetadata converts fantasy ProviderMetadata to JSON
// for storage in SDK parts.
func marshalProviderMetadata(metadata fantasy.ProviderMetadata) json.RawMessage {
if len(metadata) == 0 {
return nil
}
data, err := json.Marshal(metadata)
if err != nil {
return nil
}
return data
}
// providerMetadataToOptions converts stored JSON provider metadata
// back to fantasy ProviderOptions for LLM dispatch.
func providerMetadataToOptions(raw json.RawMessage) fantasy.ProviderOptions {
if len(raw) == 0 {
return nil
}
var opts fantasy.ProviderOptions
if err := json.Unmarshal(raw, &opts); err != nil {
return nil
}
return opts
}
// reasoningState accumulates reasoning content and provider
// metadata while the stream is in flight.
type reasoningState struct {
@@ -303,7 +336,7 @@ func Run(ctx context.Context, opts RunOptions) error {
)
})
for _, tr := range toolResults {
result.content = append(result.content, tr)
result.content = append(result.content, chatprompt.PartFromContent(tr))
}
}
@@ -428,28 +461,6 @@ func processStepStream(
activeReasoningContent := make(map[string]reasoningState)
// Track tool names by ID for input delta publishing.
toolNames := make(map[string]string)
// Track reasoning text/titles for title extraction.
reasoningTitles := make(map[string]string)
reasoningText := make(map[string]string)
setReasoningTitleFromText := func(id string, text string) {
if id == "" || strings.TrimSpace(text) == "" {
return
}
if reasoningTitles[id] != "" {
return
}
reasoningText[id] += text
if !strings.ContainsAny(reasoningText[id], "\r\n") {
return
}
title := chatprompt.ReasoningTitleFromFirstLine(reasoningText[id])
if title == "" {
return
}
reasoningTitles[id] = title
}
for part := range stream {
switch part.Type {
case fantasy.StreamPartTypeTextStart:
@@ -466,9 +477,10 @@ func processStepStream(
case fantasy.StreamPartTypeTextEnd:
if text, exists := activeTextContent[part.ID]; exists {
result.content = append(result.content, fantasy.TextContent{
result.content = append(result.content, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeText,
Text: text,
ProviderMetadata: part.ProviderMetadata,
ProviderMetadata: marshalProviderMetadata(part.ProviderMetadata),
})
delete(activeTextContent, part.ID)
}
@@ -485,12 +497,9 @@ func processStepStream(
active.options = part.ProviderMetadata
activeReasoningContent[part.ID] = active
}
setReasoningTitleFromText(part.ID, part.Delta)
title := reasoningTitles[part.ID]
publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Text: part.Delta,
Title: title,
Type: codersdk.ChatMessagePartTypeReasoning,
Text: part.Delta,
})
case fantasy.StreamPartTypeReasoningEnd:
@@ -498,27 +507,12 @@ func processStepStream(
if part.ProviderMetadata != nil {
active.options = part.ProviderMetadata
}
content := fantasy.ReasoningContent{
result.content = append(result.content, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Text: active.text,
ProviderMetadata: active.options,
}
result.content = append(result.content, content)
ProviderMetadata: marshalProviderMetadata(active.options),
})
delete(activeReasoningContent, part.ID)
// Derive reasoning title at end of reasoning
// block if we haven't yet.
if reasoningTitles[part.ID] == "" {
reasoningTitles[part.ID] = chatprompt.ReasoningTitleFromFirstLine(
reasoningText[part.ID],
)
}
title := reasoningTitles[part.ID]
if title != "" {
publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Title: title,
})
}
}
case fantasy.StreamPartTypeToolInputStart:
activeToolCalls[part.ID] = &fantasy.ToolCallContent{
@@ -556,7 +550,14 @@ func processStepStream(
ProviderMetadata: part.ProviderMetadata,
}
result.toolCalls = append(result.toolCalls, tc)
result.content = append(result.content, tc)
result.content = append(result.content, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: tc.ToolCallID,
ToolName: tc.ToolName,
Args: json.RawMessage(tc.Input),
ProviderExecuted: tc.ProviderExecuted,
ProviderMetadata: marshalProviderMetadata(tc.ProviderMetadata),
})
if strings.TrimSpace(part.ToolCallName) != "" {
toolNames[part.ID] = part.ToolCallName
}
@@ -569,17 +570,21 @@ func processStepStream(
)
case fantasy.StreamPartTypeSource:
sourceContent := fantasy.SourceContent{
SourceType: part.SourceType,
ID: part.ID,
result.content = append(result.content, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeSource,
SourceID: part.ID,
URL: part.URL,
Title: part.Title,
ProviderMetadata: part.ProviderMetadata,
}
result.content = append(result.content, sourceContent)
ProviderMetadata: marshalProviderMetadata(part.ProviderMetadata),
})
publishMessagePart(
fantasy.MessageRoleAssistant,
chatprompt.PartFromContent(sourceContent),
codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeSource,
SourceID: part.ID,
URL: part.URL,
Title: part.Title,
},
)
case fantasy.StreamPartTypeFinish:
@@ -712,16 +717,20 @@ func flushActiveState(
// Flush partial text content.
for _, text := range activeText {
if text != "" {
result.content = append(result.content, fantasy.TextContent{Text: text})
result.content = append(result.content, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeText,
Text: text,
})
}
}
// Flush partial reasoning content.
for _, rs := range activeReasoning {
if rs.text != "" {
result.content = append(result.content, fantasy.ReasoningContent{
result.content = append(result.content, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Text: rs.text,
ProviderMetadata: rs.options,
ProviderMetadata: marshalProviderMetadata(rs.options),
})
}
}
@@ -747,7 +756,13 @@ func flushActiveState(
Input: tc.Input,
ProviderExecuted: tc.ProviderExecuted,
}
result.content = append(result.content, flushed)
result.content = append(result.content, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: flushed.ToolCallID,
ToolName: flushed.ToolName,
Args: json.RawMessage(flushed.Input),
ProviderExecuted: flushed.ProviderExecuted,
})
result.toolCalls = append(result.toolCalls, flushed)
}
}
@@ -767,15 +782,14 @@ func persistInterruptedStep(
// Track which tool calls already have results in the content.
answeredToolCalls := make(map[string]struct{})
for _, c := range result.content {
tr, ok := fantasy.AsContentType[fantasy.ToolResultContent](c)
if ok && tr.ToolCallID != "" {
answeredToolCalls[tr.ToolCallID] = struct{}{}
if c.Type == codersdk.ChatMessagePartTypeToolResult && c.ToolCallID != "" {
answeredToolCalls[c.ToolCallID] = struct{}{}
}
}
// Build combined content: all accumulated content + synthetic
// interrupted results for any unanswered tool calls.
content := make([]fantasy.Content, 0, len(result.content))
content := make([]codersdk.ChatMessagePart, 0, len(result.content))
content = append(content, result.content...)
for _, tc := range result.toolCalls {
@@ -785,12 +799,13 @@ func persistInterruptedStep(
if _, exists := answeredToolCalls[tc.ToolCallID]; exists {
continue
}
content = append(content, fantasy.ToolResultContent{
errResult, _ := json.Marshal(map[string]any{"error": interruptedToolResultErrorMessage})
content = append(content, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolResult,
ToolCallID: tc.ToolCallID,
ToolName: tc.ToolName,
Result: fantasy.ToolResultOutputContentError{
Error: xerrors.New(interruptedToolResultErrorMessage),
},
Result: errResult,
IsError: true,
})
answeredToolCalls[tc.ToolCallID] = struct{}{}
}
+16 -19
View File
@@ -11,6 +11,8 @@ import (
fantasyanthropic "charm.land/fantasy/providers/anthropic"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
const activeToolName = "read_file"
@@ -129,7 +131,7 @@ func TestRun_InterruptedStepPersistsSyntheticToolResult(t *testing.T) {
}()
persistedAssistantCtxErr := xerrors.New("unset")
var persistedContent []fantasy.Content
var persistedContent []codersdk.ChatMessagePart
err := Run(ctx, RunOptions{
Model: model,
@@ -142,9 +144,8 @@ func TestRun_InterruptedStepPersistsSyntheticToolResult(t *testing.T) {
MaxSteps: 3,
PersistStep: func(persistCtx context.Context, step PersistedStep) error {
persistedAssistantCtxErr = persistCtx.Err()
persistedContent = append([]fantasy.Content(nil), step.Content...)
return nil
},
persistedContent = append([]codersdk.ChatMessagePart(nil), step.Content...)
return nil },
})
require.ErrorIs(t, err, ErrInterrupted)
require.NoError(t, persistedAssistantCtxErr)
@@ -156,25 +157,21 @@ func TestRun_InterruptedStepPersistsSyntheticToolResult(t *testing.T) {
foundToolResult bool
)
for _, block := range persistedContent {
if text, ok := fantasy.AsContentType[fantasy.TextContent](block); ok {
if strings.Contains(text.Text, "partial assistant output") {
switch {
case block.Type == codersdk.ChatMessagePartTypeText:
if strings.Contains(block.Text, "partial assistant output") {
foundText = true
}
continue
}
if toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block); ok {
if toolCall.ToolCallID == "interrupt-tool-1" &&
toolCall.ToolName == "read_file" &&
strings.Contains(toolCall.Input, `"path":"main.go"`) {
case block.Type == codersdk.ChatMessagePartTypeToolCall:
if block.ToolCallID == "interrupt-tool-1" &&
block.ToolName == "read_file" &&
strings.Contains(string(block.Args), `"path":"main.go"`) {
foundToolCall = true
}
continue
}
if toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block); ok {
if toolResult.ToolCallID == "interrupt-tool-1" &&
toolResult.ToolName == "read_file" {
_, isErr := toolResult.Result.(fantasy.ToolResultOutputContentError)
require.True(t, isErr, "interrupted tool result should be an error")
case block.Type == codersdk.ChatMessagePartTypeToolResult:
if block.ToolCallID == "interrupt-tool-1" &&
block.ToolName == "read_file" {
require.True(t, block.IsError, "interrupted tool result should be an error")
foundToolResult = true
}
}
+302 -476
View File
@@ -3,11 +3,11 @@ package chatprompt
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"charm.land/fantasy"
fantasyopenai "charm.land/fantasy/providers/openai"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
@@ -49,65 +49,8 @@ func ExtractFileID(raw json.RawMessage) (uuid.UUID, error) {
return uuid.Parse(envelope.Data.FileID)
}
// extractFileIDs scans raw message content for file_id references.
// Returns a map of block index to file ID. Returns nil for
// non-array content or content with no file references.
func extractFileIDs(raw pqtype.NullRawMessage) map[int]uuid.UUID {
if !raw.Valid || len(raw.RawMessage) == 0 {
return nil
}
var rawBlocks []json.RawMessage
if err := json.Unmarshal(raw.RawMessage, &rawBlocks); err != nil {
return nil
}
var result map[int]uuid.UUID
for i, block := range rawBlocks {
fid, err := ExtractFileID(block)
if err == nil {
if result == nil {
result = make(map[int]uuid.UUID)
}
result[i] = fid
}
}
return result
}
// patchFileContent fills in empty Data on FileContent blocks from
// resolved file data. Blocks that already have inline data (backward
// compat) or have no resolved data are left unchanged.
func patchFileContent(
content []fantasy.Content,
fileIDs map[int]uuid.UUID,
resolved map[uuid.UUID]FileData,
) {
for blockIdx, fid := range fileIDs {
if blockIdx >= len(content) {
continue
}
switch fc := content[blockIdx].(type) {
case fantasy.FileContent:
if len(fc.Data) > 0 {
continue
}
if data, found := resolved[fid]; found {
fc.Data = data.Data
content[blockIdx] = fc
}
case *fantasy.FileContent:
if len(fc.Data) > 0 {
continue
}
if data, found := resolved[fid]; found {
fc.Data = data.Data
}
}
}
}
// ConvertMessages converts persisted chat messages into LLM prompt
// messages without resolving file references from storage. Inline
// file data is preserved when present (backward compat).
// messages without resolving file references from storage.
func ConvertMessages(
messages []database.ChatMessage,
) ([]fantasy.Message, error) {
@@ -116,42 +59,92 @@ func ConvertMessages(
// ConvertMessagesWithFiles converts persisted chat messages into LLM
// prompt messages, resolving file references via the provided
// resolver. When resolver is nil, file blocks without inline data
// are passed through as-is (same behavior as ConvertMessages).
// resolver.
func ConvertMessagesWithFiles(
ctx context.Context,
messages []database.ChatMessage,
resolver FileResolver,
) ([]fantasy.Message, error) {
// Phase 1: Pre-scan user messages for file_id references.
// Phase 1: Parse all messages and collect file IDs from user messages.
type parsedMsg struct {
role string
parts []codersdk.ChatMessagePart
fileIDs map[int]uuid.UUID // block index → file UUID (file parts only)
}
parsed := make([]parsedMsg, len(messages))
var allFileIDs []uuid.UUID
seenFileIDs := make(map[uuid.UUID]struct{})
fileIDsByMsg := make(map[int]map[int]uuid.UUID)
if resolver != nil {
for i, msg := range messages {
visibility := msg.Visibility
if visibility == "" {
visibility = database.ChatMessageVisibilityBoth
for i, msg := range messages {
visibility := msg.Visibility
if visibility == "" {
visibility = database.ChatMessageVisibilityBoth
}
if visibility != database.ChatMessageVisibilityModel &&
visibility != database.ChatMessageVisibilityBoth {
continue
}
if msg.Role == string(fantasy.MessageRoleSystem) {
content, err := parseSystemContent(msg.Content)
if err != nil {
return nil, err
}
if visibility != database.ChatMessageVisibilityModel &&
visibility != database.ChatMessageVisibilityBoth {
continue
if strings.TrimSpace(content) != "" {
parsed[i] = parsedMsg{
role: msg.Role,
parts: []codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeText,
Text: content,
}},
}
}
if msg.Role != string(fantasy.MessageRoleUser) {
continue
continue
}
if msg.Role == string(fantasy.MessageRoleTool) {
rows, err := parseToolResultRows(msg.Content)
if err != nil {
return nil, err
}
fids := extractFileIDs(msg.Content)
if len(fids) > 0 {
fileIDsByMsg[i] = fids
for _, fid := range fids {
if _, seen := seenFileIDs[fid]; !seen {
seenFileIDs[fid] = struct{}{}
allFileIDs = append(allFileIDs, fid)
toolParts := make([]codersdk.ChatMessagePart, 0, len(rows))
for _, row := range rows {
toolParts = append(toolParts, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolResult,
ToolCallID: row.ToolCallID,
ToolName: row.ToolName,
Result: row.Result,
IsError: row.IsError,
})
}
parsed[i] = parsedMsg{role: msg.Role, parts: toolParts}
continue
}
// User and assistant messages.
parts, err := ParseContent(msg.Role, msg.Content)
if err != nil {
return nil, err
}
pm := parsedMsg{role: msg.Role, parts: parts}
// Collect file IDs from user messages for batch resolution.
if resolver != nil && msg.Role == string(fantasy.MessageRoleUser) {
for j, part := range parts {
if part.Type == codersdk.ChatMessagePartTypeFile && part.FileID.Valid {
if pm.fileIDs == nil {
pm.fileIDs = make(map[int]uuid.UUID)
}
pm.fileIDs[j] = part.FileID.UUID
if _, seen := seenFileIDs[part.FileID.UUID]; !seen {
seenFileIDs[part.FileID.UUID] = struct{}{}
allFileIDs = append(allFileIDs, part.FileID.UUID)
}
}
}
}
parsed[i] = pm
}
// Phase 2: Batch resolve file data.
@@ -164,53 +157,31 @@ func ConvertMessagesWithFiles(
}
}
// Phase 3: Convert messages, patching file content as needed.
// Phase 3: Build fantasy prompt messages.
prompt := make([]fantasy.Message, 0, len(messages))
toolNameByCallID := make(map[string]string)
for i, message := range messages {
visibility := message.Visibility
if visibility == "" {
visibility = database.ChatMessageVisibilityBoth
}
if visibility != database.ChatMessageVisibilityModel &&
visibility != database.ChatMessageVisibilityBoth {
for _, pm := range parsed {
if pm.parts == nil {
continue
}
switch message.Role {
switch pm.role {
case string(fantasy.MessageRoleSystem):
content, err := parseSystemContent(message.Content)
if err != nil {
return nil, err
}
if strings.TrimSpace(content) == "" {
continue
}
prompt = append(prompt, fantasy.Message{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: content},
fantasy.TextPart{Text: pm.parts[0].Text},
},
})
case string(fantasy.MessageRoleUser):
content, err := ParseContent(string(fantasy.MessageRoleUser), message.Content)
if err != nil {
return nil, err
}
if fids, ok := fileIDsByMsg[i]; ok {
patchFileContent(content, fids, resolved)
}
msgParts := partsToMessageParts(pm.parts, pm.fileIDs, resolved)
prompt = append(prompt, fantasy.Message{
Role: fantasy.MessageRoleUser,
Content: ToMessageParts(content),
Content: msgParts,
})
case string(fantasy.MessageRoleAssistant):
content, err := ParseContent(string(fantasy.MessageRoleAssistant), message.Content)
if err != nil {
return nil, err
}
parts := normalizeAssistantToolCallInputs(ToMessageParts(content))
for _, toolCall := range ExtractToolCalls(parts) {
msgParts := partsToMessageParts(pm.parts, nil, nil)
msgParts = normalizeAssistantToolCallInputs(msgParts)
for _, toolCall := range ExtractToolCalls(msgParts) {
if toolCall.ToolCallID == "" || strings.TrimSpace(toolCall.ToolName) == "" {
continue
}
@@ -218,33 +189,24 @@ func ConvertMessagesWithFiles(
}
prompt = append(prompt, fantasy.Message{
Role: fantasy.MessageRoleAssistant,
Content: parts,
Content: msgParts,
})
case string(fantasy.MessageRoleTool):
rows, err := parseToolResultRows(message.Content)
if err != nil {
return nil, err
}
parts := make([]fantasy.MessagePart, 0, len(rows))
for _, row := range rows {
if row.ToolCallID != "" && row.ToolName != "" {
toolNameByCallID[sanitizeToolCallID(row.ToolCallID)] = row.ToolName
parts := make([]fantasy.MessagePart, 0, len(pm.parts))
for _, part := range pm.parts {
if part.ToolCallID != "" && part.ToolName != "" {
toolNameByCallID[sanitizeToolCallID(part.ToolCallID)] = part.ToolName
}
parts = append(parts, row.toToolResultPart())
parts = append(parts, toolResultPartToMessagePart(part))
}
prompt = append(prompt, fantasy.Message{
Role: fantasy.MessageRoleTool,
Content: parts,
})
default:
return nil, xerrors.Errorf("unsupported chat message role %q", message.Role)
}
}
prompt = injectMissingToolResults(prompt)
prompt = injectMissingToolUses(
prompt,
toolNameByCallID,
)
prompt = injectMissingToolUses(prompt, toolNameByCallID)
return prompt, nil
}
@@ -329,31 +291,95 @@ func AppendUser(prompt []fantasy.Message, instruction string) []fantasy.Message
return out
}
// ParseContent decodes persisted chat message content blocks.
func ParseContent(role string, raw pqtype.NullRawMessage) ([]fantasy.Content, error) {
// ParseContent decodes persisted chat message content into SDK
// parts. Handles both the new SDK format and legacy fantasy envelope
// format for backward compatibility.
func ParseContent(role string, raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
if !raw.Valid || len(raw.RawMessage) == 0 {
return nil, nil
}
// Plain JSON string (system messages, some legacy user messages).
var text string
if err := json.Unmarshal(raw.RawMessage, &text); err == nil {
return []fantasy.Content{fantasy.TextContent{Text: text}}, nil
return []codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeText,
Text: text,
}}, nil
}
// Try SDK format first (new storage format for all roles).
if !IsFantasyEnvelopeFormat(raw.RawMessage) {
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(raw.RawMessage, &parts); err == nil && len(parts) > 0 {
return parts, nil
}
}
// Fall back to fantasy envelope format (legacy rows).
var rawBlocks []json.RawMessage
if err := json.Unmarshal(raw.RawMessage, &rawBlocks); err != nil {
return nil, xerrors.Errorf("parse %s content: %w", role, err)
}
content := make([]fantasy.Content, 0, len(rawBlocks))
parts := make([]codersdk.ChatMessagePart, 0, len(rawBlocks))
for i, rawBlock := range rawBlocks {
block, err := fantasy.UnmarshalContent(rawBlock)
if err != nil {
return nil, xerrors.Errorf("parse %s content block %d: %w", role, i, err)
}
content = append(content, block)
part := PartFromContent(block)
if part.Type == "" {
continue
}
// For file blocks in fantasy envelope, extract file_id.
if part.Type == codersdk.ChatMessagePartTypeFile {
if fid, err := ExtractFileID(rawBlock); err == nil {
part.FileID = uuid.NullUUID{UUID: fid, Valid: true}
if len(part.Data) == 0 {
part.Data = nil // Resolved at LLM dispatch time.
}
}
}
parts = append(parts, part)
}
return content, nil
return parts, nil
}
// fileReferencePartToText converts a ChatMessagePart file-reference
// into the text representation used for LLM dispatch.
func fileReferencePartToText(part codersdk.ChatMessagePart) string {
lineRange := fmt.Sprintf("%d", part.StartLine)
if part.StartLine != part.EndLine {
lineRange = fmt.Sprintf("%d-%d", part.StartLine, part.EndLine)
}
var sb strings.Builder
_, _ = fmt.Fprintf(&sb, "[file-reference] %s:%s", part.FileName, lineRange)
if strings.TrimSpace(part.Content) != "" {
_, _ = fmt.Fprintf(&sb, "\n```%s\n%s\n```", part.FileName, strings.TrimSpace(part.Content))
}
return sb.String()
}
// IsFantasyEnvelopeFormat checks whether the JSON content uses the
// fantasy {"type": ..., "data": {...}} envelope format. Returns
// false for the newer SDK ChatMessagePart format or non-array
// content.
func IsFantasyEnvelopeFormat(raw json.RawMessage) bool {
var blocks []json.RawMessage
if err := json.Unmarshal(raw, &blocks); err != nil || len(blocks) == 0 {
return false
}
var probe struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(blocks[0], &probe); err != nil {
return false
}
// Fantasy envelope wraps content in "data" as a JSON object.
// The SDK ChatMessagePart.Data field serializes as a base64
// string (not an object), so checking for '{' is safe.
return len(probe.Data) > 0 && probe.Data[0] == '{'
}
// toolResultRaw is an untyped representation of a persisted tool
@@ -426,74 +452,116 @@ func extractErrorString(raw json.RawMessage) string {
return strings.TrimSpace(s)
}
// ToMessageParts converts fantasy content blocks into message parts.
func ToMessageParts(content []fantasy.Content) []fantasy.MessagePart {
parts := make([]fantasy.MessagePart, 0, len(content))
for _, block := range content {
switch value := block.(type) {
case fantasy.TextContent:
parts = append(parts, fantasy.TextPart{
Text: value.Text,
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
// partsToMessageParts converts SDK parts to fantasy MessageParts for
// LLM dispatch. For file parts with resolved data, the file content
// is injected. For file-reference parts, a text representation is
// used.
func partsToMessageParts(
parts []codersdk.ChatMessagePart,
fileIDs map[int]uuid.UUID,
resolved map[uuid.UUID]FileData,
) []fantasy.MessagePart {
out := make([]fantasy.MessagePart, 0, len(parts))
for i, part := range parts {
switch part.Type {
case codersdk.ChatMessagePartTypeText:
out = append(out, fantasy.TextPart{
Text: part.Text,
ProviderOptions: providerMetadataToOptions(part.ProviderMetadata),
})
case *fantasy.TextContent:
parts = append(parts, fantasy.TextPart{
Text: value.Text,
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
case codersdk.ChatMessagePartTypeReasoning:
out = append(out, fantasy.ReasoningPart{
Text: part.Text,
ProviderOptions: providerMetadataToOptions(part.ProviderMetadata),
})
case fantasy.ReasoningContent:
parts = append(parts, fantasy.ReasoningPart{
Text: value.Text,
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
case codersdk.ChatMessagePartTypeToolCall:
out = append(out, fantasy.ToolCallPart{
ToolCallID: sanitizeToolCallID(part.ToolCallID),
ToolName: part.ToolName,
Input: string(part.Args),
ProviderExecuted: part.ProviderExecuted,
ProviderOptions: providerMetadataToOptions(part.ProviderMetadata),
})
case *fantasy.ReasoningContent:
parts = append(parts, fantasy.ReasoningPart{
Text: value.Text,
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
case codersdk.ChatMessagePartTypeFile:
fileData := part.Data
mediaType := part.MediaType
// Inject resolved file data if available.
if fid, ok := fileIDs[i]; ok {
if data, found := resolved[fid]; found {
fileData = data.Data
if mediaType == "" {
mediaType = data.MediaType
}
}
}
out = append(out, fantasy.FilePart{
Data: fileData,
MediaType: mediaType,
ProviderOptions: providerMetadataToOptions(part.ProviderMetadata),
})
case fantasy.ToolCallContent:
parts = append(parts, fantasy.ToolCallPart{
ToolCallID: sanitizeToolCallID(value.ToolCallID),
ToolName: value.ToolName,
Input: value.Input,
ProviderExecuted: value.ProviderExecuted,
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
case codersdk.ChatMessagePartTypeFileReference:
out = append(out, fantasy.TextPart{
Text: fileReferencePartToText(part),
})
case *fantasy.ToolCallContent:
parts = append(parts, fantasy.ToolCallPart{
ToolCallID: sanitizeToolCallID(value.ToolCallID),
ToolName: value.ToolName,
Input: value.Input,
ProviderExecuted: value.ProviderExecuted,
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
})
case fantasy.FileContent:
parts = append(parts, fantasy.FilePart{
Data: value.Data,
MediaType: value.MediaType,
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
})
case *fantasy.FileContent:
parts = append(parts, fantasy.FilePart{
Data: value.Data,
MediaType: value.MediaType,
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
})
case fantasy.ToolResultContent:
parts = append(parts, fantasy.ToolResultPart{
ToolCallID: sanitizeToolCallID(value.ToolCallID),
Output: value.Result,
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
})
case *fantasy.ToolResultContent:
parts = append(parts, fantasy.ToolResultPart{
ToolCallID: sanitizeToolCallID(value.ToolCallID),
Output: value.Result,
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
case codersdk.ChatMessagePartTypeSource:
// Sources don't have a direct fantasy.MessagePart equivalent;
// pass as text for LLM context.
out = append(out, fantasy.TextPart{
Text: fmt.Sprintf("[source: %s](%s)", part.Title, part.URL),
})
case codersdk.ChatMessagePartTypeToolResult:
out = append(out, toolResultPartToMessagePart(part))
}
}
return parts
return out
}
// toolResultPartToMessagePart converts an SDK tool-result part to a
// fantasy ToolResultPart for LLM dispatch.
func toolResultPartToMessagePart(part codersdk.ChatMessagePart) fantasy.ToolResultPart {
var output fantasy.ToolResultOutputContent
if part.IsError {
errMsg := ""
if len(part.Result) > 0 {
var errObj struct {
Error string `json:"error"`
}
if json.Unmarshal(part.Result, &errObj) == nil && errObj.Error != "" {
errMsg = errObj.Error
} else {
errMsg = string(part.Result)
}
}
output = fantasy.ToolResultOutputContentError{
Error: xerrors.New(errMsg),
}
} else if len(part.Result) > 0 {
output = fantasy.ToolResultOutputContentText{
Text: string(part.Result),
}
}
return fantasy.ToolResultPart{
ToolCallID: sanitizeToolCallID(part.ToolCallID),
Output: output,
ProviderOptions: providerMetadataToOptions(part.ProviderMetadata),
}
}
// providerMetadataToOptions converts stored provider metadata JSON
// back into fantasy ProviderOptions for LLM dispatch round-trip.
func providerMetadataToOptions(raw json.RawMessage) fantasy.ProviderOptions {
if len(raw) == 0 {
return nil
}
var intermediate map[string]json.RawMessage
if err := json.Unmarshal(raw, &intermediate); err != nil {
return nil
}
opts, err := fantasy.UnmarshalProviderOptions(intermediate)
if err != nil {
return nil
}
return opts
}
func normalizeAssistantToolCallInputs(
@@ -513,6 +581,20 @@ func normalizeAssistantToolCallInputs(
return normalized
}
// safeToolCallArgs converts a tool call input string to a
// json.RawMessage, returning nil for invalid JSON to avoid
// serialization failures in MarshalParts.
func safeToolCallArgs(input string) json.RawMessage {
if input == "" {
return nil
}
raw := json.RawMessage(input)
if !json.Valid(raw) {
return nil
}
return raw
}
// normalizeToolCallInput guarantees tool call input is a JSON object string.
// Anthropic drops assistant tool calls with malformed input, which can leave
// following tool results orphaned.
@@ -548,114 +630,36 @@ func ExtractToolCalls(parts []fantasy.MessagePart) []fantasy.ToolCallContent {
return toolCalls
}
// MarshalContent encodes message content blocks for persistence.
// fileIDs optionally maps block indices to chat_files IDs, which
// are injected into the JSON envelope for file-type blocks so
// the reference survives round-trips through storage.
func MarshalContent(blocks []fantasy.Content, fileIDs map[int]uuid.UUID) (pqtype.NullRawMessage, error) {
if len(blocks) == 0 {
// MarshalParts serializes message parts for database persistence.
// All roles now use the same SDK-native JSON format:
// [{"type":"text","text":"..."}, {"type":"tool-call",...}, ...].
func MarshalParts(parts []codersdk.ChatMessagePart) (pqtype.NullRawMessage, error) {
if len(parts) == 0 {
return pqtype.NullRawMessage{}, nil
}
encodedBlocks := make([]json.RawMessage, 0, len(blocks))
for i, block := range blocks {
encoded, err := marshalContentBlock(block)
if err != nil {
return pqtype.NullRawMessage{}, xerrors.Errorf(
"encode content block %d: %w",
i,
err,
)
}
if fid, ok := fileIDs[i]; ok {
encoded, err = injectFileID(encoded, fid)
if err != nil {
return pqtype.NullRawMessage{}, xerrors.Errorf(
"inject file_id into content block %d: %w",
i,
err,
)
}
}
encodedBlocks = append(encodedBlocks, encoded)
}
data, err := json.Marshal(encodedBlocks)
data, err := json.Marshal(parts)
if err != nil {
return pqtype.NullRawMessage{}, xerrors.Errorf("encode content blocks: %w", err)
return pqtype.NullRawMessage{}, xerrors.Errorf("marshal content parts: %w", err)
}
return pqtype.NullRawMessage{RawMessage: data, Valid: true}, nil
}
// injectFileID adds a file_id field into the data sub-object of a
// serialized content block envelope. This follows the same pattern
// as the reasoning title injection in marshalContentBlock.
func injectFileID(encoded json.RawMessage, fileID uuid.UUID) (json.RawMessage, error) {
var envelope struct {
Type string `json:"type"`
Data struct {
MediaType string `json:"media_type"`
Data json.RawMessage `json:"data,omitempty"`
FileID string `json:"file_id,omitempty"`
ProviderMetadata *json.RawMessage `json:"provider_metadata,omitempty"`
} `json:"data"`
}
if err := json.Unmarshal(encoded, &envelope); err != nil {
return encoded, err
}
envelope.Data.FileID = fileID.String()
envelope.Data.Data = nil // Strip inline data; resolved at LLM dispatch time.
return json.Marshal(envelope)
}
// MarshalToolResult encodes a single tool result for persistence as
// an opaque JSON blob. The stored shape is
// [{"tool_call_id":…,"tool_name":…,"result":…,"is_error":…}].
// MarshalToolResult encodes a single tool result for persistence.
func MarshalToolResult(toolCallID, toolName string, result json.RawMessage, isError bool) (pqtype.NullRawMessage, error) {
row := toolResultRaw{
return MarshalParts([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeToolResult,
ToolCallID: toolCallID,
ToolName: toolName,
Result: result,
IsError: isError,
}
data, err := json.Marshal([]toolResultRaw{row})
if err != nil {
return pqtype.NullRawMessage{}, xerrors.Errorf("encode tool result: %w", err)
}
return pqtype.NullRawMessage{RawMessage: data, Valid: true}, nil
}})
}
// MarshalToolResultContent encodes a fantasy tool result content
// block for persistence. It extracts the raw fields and delegates
// to MarshalToolResult.
// for persistence as a single-element SDK parts array.
func MarshalToolResultContent(content fantasy.ToolResultContent) (pqtype.NullRawMessage, error) {
var result json.RawMessage
var isError bool
switch output := content.Result.(type) {
case fantasy.ToolResultOutputContentError:
isError = true
if output.Error != nil {
result, _ = json.Marshal(map[string]any{"error": output.Error.Error()})
} else {
result = []byte(`{"error":""}`)
}
case fantasy.ToolResultOutputContentText:
result = json.RawMessage(output.Text)
if !json.Valid(result) {
result, _ = json.Marshal(map[string]any{"output": output.Text})
}
case fantasy.ToolResultOutputContentMedia:
result, _ = json.Marshal(map[string]any{
"data": output.Data,
"mime_type": output.MediaType,
"text": output.Text,
})
default:
result = []byte(`{}`)
}
return MarshalToolResult(content.ToolCallID, content.ToolName, result, isError)
part := ToolResultContentToPart(content)
return MarshalParts([]codersdk.ChatMessagePart{part})
}
// PartFromContent converts fantasy content into a SDK chat message part.
@@ -673,29 +677,29 @@ func PartFromContent(block fantasy.Content) codersdk.ChatMessagePart {
}
case fantasy.ReasoningContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Text: value.Text,
Title: reasoningSummaryTitle(value.ProviderMetadata),
Type: codersdk.ChatMessagePartTypeReasoning,
Text: value.Text,
}
case *fantasy.ReasoningContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Text: value.Text,
Title: reasoningSummaryTitle(value.ProviderMetadata),
Type: codersdk.ChatMessagePartTypeReasoning,
Text: value.Text,
}
case fantasy.ToolCallContent:
args := safeToolCallArgs(value.Input)
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
Args: args,
}
case *fantasy.ToolCallContent:
args := safeToolCallArgs(value.Input)
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
Args: args,
}
case fantasy.SourceContent:
return codersdk.ChatMessagePart{
@@ -724,9 +728,9 @@ func PartFromContent(block fantasy.Content) codersdk.ChatMessagePart {
Data: value.Data,
}
case fantasy.ToolResultContent:
return toolResultContentToPart(value)
return ToolResultContentToPart(value)
case *fantasy.ToolResultContent:
return toolResultContentToPart(*value)
return ToolResultContentToPart(*value)
default:
return codersdk.ChatMessagePart{}
}
@@ -745,9 +749,9 @@ func ToolResultToPart(toolCallID, toolName string, result json.RawMessage, isErr
}
}
// toolResultContentToPart converts a fantasy ToolResultContent
// ToolResultContentToPart converts a fantasy ToolResultContent
// directly into a ChatMessagePart without an intermediate struct.
func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMessagePart {
func ToolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMessagePart {
var result json.RawMessage
var isError bool
@@ -778,42 +782,6 @@ func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMes
return ToolResultToPart(content.ToolCallID, content.ToolName, result, isError)
}
// ReasoningTitleFromFirstLine extracts a compact markdown title.
func ReasoningTitleFromFirstLine(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
firstLine := text
if idx := strings.IndexAny(firstLine, "\r\n"); idx >= 0 {
firstLine = firstLine[:idx]
}
firstLine = strings.TrimSpace(firstLine)
if firstLine == "" || !strings.HasPrefix(firstLine, "**") {
return ""
}
rest := firstLine[2:]
end := strings.Index(rest, "**")
if end < 0 {
return ""
}
title := strings.TrimSpace(rest[:end])
if title == "" {
return ""
}
// Require the first line to be exactly "**title**" (ignoring
// surrounding whitespace) so providers without this format don't
// accidentally emit a title.
if strings.TrimSpace(rest[end+2:]) != "" {
return ""
}
return compactReasoningSummaryTitle(title)
}
func injectMissingToolResults(prompt []fantasy.Message) []fantasy.Message {
result := make([]fantasy.Message, 0, len(prompt))
@@ -1018,148 +986,6 @@ func sanitizeToolCallID(id string) string {
return toolCallIDSanitizer.ReplaceAllString(id, "_")
}
func marshalContentBlock(block fantasy.Content) (json.RawMessage, error) {
encoded, err := json.Marshal(block)
if err != nil {
return nil, err
}
title, ok := reasoningTitleFromContent(block)
if !ok || title == "" {
return encoded, nil
}
var envelope struct {
Type string `json:"type"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(encoded, &envelope); err != nil {
return nil, err
}
if !strings.EqualFold(envelope.Type, string(fantasy.ContentTypeReasoning)) {
return encoded, nil
}
if envelope.Data == nil {
envelope.Data = map[string]any{}
}
envelope.Data["title"] = title
encodedWithTitle, err := json.Marshal(envelope)
if err != nil {
return nil, err
}
return encodedWithTitle, nil
}
func reasoningTitleFromContent(block fantasy.Content) (string, bool) {
switch value := block.(type) {
case fantasy.ReasoningContent:
return ReasoningTitleFromFirstLine(value.Text), true
case *fantasy.ReasoningContent:
if value == nil {
return "", false
}
return ReasoningTitleFromFirstLine(value.Text), true
default:
return "", false
}
}
func reasoningSummaryTitle(metadata fantasy.ProviderMetadata) string {
if len(metadata) == 0 {
return ""
}
reasoningMetadata := fantasyopenai.GetReasoningMetadata(
fantasy.ProviderOptions(metadata),
)
if reasoningMetadata == nil {
return ""
}
for _, summary := range reasoningMetadata.Summary {
if title := compactReasoningSummaryTitle(summary); title != "" {
return title
}
}
return ""
}
func compactReasoningSummaryTitle(summary string) string {
const maxWords = 8
const maxRunes = 80
summary = strings.TrimSpace(summary)
if summary == "" {
return ""
}
summary = strings.Trim(summary, "\"'`")
summary = reasoningSummaryHeadline(summary)
words := strings.Fields(summary)
if len(words) == 0 {
return ""
}
truncated := false
if len(words) > maxWords {
words = words[:maxWords]
truncated = true
}
title := strings.Join(words, " ")
if truncated {
title += "…"
}
return truncateRunes(title, maxRunes)
}
func reasoningSummaryHeadline(summary string) string {
summary = strings.TrimSpace(summary)
if summary == "" {
return ""
}
// OpenAI summary_text may be markdown like:
// "**Title**\n\nLonger explanation ...".
// Keep only the heading segment for UI titles.
if idx := strings.Index(summary, "\n\n"); idx >= 0 {
summary = summary[:idx]
}
if idx := strings.IndexAny(summary, "\r\n"); idx >= 0 {
summary = summary[:idx]
}
summary = strings.TrimSpace(summary)
if summary == "" {
return ""
}
if strings.HasPrefix(summary, "**") {
rest := summary[2:]
if end := strings.Index(rest, "**"); end >= 0 {
bold := strings.TrimSpace(rest[:end])
if bold != "" {
summary = bold
}
}
}
return strings.TrimSpace(strings.Trim(summary, "\"'`"))
}
func truncateRunes(value string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= maxLen {
return value
}
return string(runes[:maxLen])
}
+5 -41
View File
@@ -12,6 +12,7 @@ import (
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
func TestConvertMessages_NormalizesAssistantToolCallInput(t *testing.T) {
@@ -49,13 +50,13 @@ func TestConvertMessages_NormalizesAssistantToolCallInput(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assistantContent, err := chatprompt.MarshalContent([]fantasy.Content{
fantasy.ToolCallContent{
assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
chatprompt.PartFromContent(fantasy.ToolCallContent{
ToolCallID: "toolu_01C4PqN6F2493pi7Ebag8Vg7",
ToolName: "execute",
Input: tc.input,
},
}, nil)
}),
})
require.NoError(t, err)
toolContent, err := chatprompt.MarshalToolResult(
@@ -185,43 +186,6 @@ func TestConvertMessagesWithFiles_BackwardCompat(t *testing.T) {
require.Equal(t, inlineData, filePart.Data)
}
func TestInjectFileID_StripsInlineData(t *testing.T) {
t.Parallel()
fileID := uuid.New()
imageData := []byte("raw-image-bytes")
// Marshal a file content block with inline data, then inject
// a file_id. The result should have file_id but no data.
content, err := chatprompt.MarshalContent([]fantasy.Content{
fantasy.FileContent{
MediaType: "image/png",
Data: imageData,
},
}, map[int]uuid.UUID{0: fileID})
require.NoError(t, err)
// Parse the stored content to verify shape.
var blocks []json.RawMessage
require.NoError(t, json.Unmarshal(content.RawMessage, &blocks))
require.Len(t, blocks, 1)
var envelope struct {
Type string `json:"type"`
Data struct {
MediaType string `json:"media_type"`
Data *json.RawMessage `json:"data,omitempty"`
FileID string `json:"file_id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(blocks[0], &envelope))
require.Equal(t, "file", envelope.Type)
require.Equal(t, "image/png", envelope.Data.MediaType)
require.Equal(t, fileID.String(), envelope.Data.FileID)
// Data should be nil (omitted) since injectFileID strips it.
require.Nil(t, envelope.Data.Data, "inline data should be stripped")
}
func mustJSON(t *testing.T, v any) json.RawMessage {
t.Helper()
data, err := json.Marshal(v)
+12 -5
View File
@@ -13,6 +13,7 @@ import (
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
var ErrSubagentNotDescendant = xerrors.New("target chat is not a descendant of current chat")
@@ -250,9 +251,12 @@ func (p *Server) createChildSubagentChat(
UUID: rootChatID,
Valid: true,
},
ModelConfigID: parent.LastModelConfigID,
Title: title,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: prompt}},
ModelConfigID: parent.LastModelConfigID,
Title: title,
Content: []codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeText,
Text: prompt,
}},
})
if err != nil {
return database.Chat{}, xerrors.Errorf("create child chat: %w", err)
@@ -282,8 +286,11 @@ func (p *Server) sendSubagentMessage(
}
sendResult, err := p.SendMessage(ctx, SendMessageOptions{
ChatID: targetChatID,
Content: []fantasy.Content{fantasy.TextContent{Text: message}},
ChatID: targetChatID,
Content: []codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeText,
Text: message,
}},
BusyBehavior: busyBehavior,
})
if err != nil {
+22 -4
View File
@@ -20,6 +20,7 @@ import (
"github.com/coder/coder/v2/coderd/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/chatd/chatretry"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
)
@@ -158,9 +159,8 @@ func generateTitle(
return "", xerrors.Errorf("generate title text: %w", err)
}
title := normalizeTitleOutput(contentBlocksToText(response.Content))
if title == "" {
return "", xerrors.New("generated title was empty")
title := normalizeTitleOutput(responseContentToText(response.Content))
if title == "" { return "", xerrors.New("generated title was empty")
}
return title, nil
}
@@ -252,7 +252,25 @@ func fallbackChatTitle(message string) string {
// contentBlocksToText concatenates the text parts of content blocks
// into a single space-separated string.
func contentBlocksToText(content []fantasy.Content) string {
func contentBlocksToText(content []codersdk.ChatMessagePart) string {
parts := make([]string, 0, len(content))
for _, block := range content {
if block.Type != codersdk.ChatMessagePartTypeText {
continue
}
text := strings.TrimSpace(block.Text)
if text == "" {
continue
}
parts = append(parts, text)
}
return strings.Join(parts, " ")
}
// responseContentToText extracts text from a fantasy response
// content slice. Used for title generation where the response
// comes directly from fantasy.Response, not from persisted parts.
func responseContentToText(content []fantasy.Content) string {
parts := make([]string, 0, len(content))
for _, block := range content {
textBlock, ok := fantasy.AsContentType[fantasy.TextContent](block)
+41 -45
View File
@@ -19,7 +19,6 @@ import (
"sync"
"time"
"charm.land/fantasy"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/xerrors"
@@ -251,7 +250,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
return
}
contentBlocks, contentFileIDs, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req)
contentParts, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req)
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError)
return
@@ -280,13 +279,12 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
}
chat, err := api.chatDaemon.CreateChat(ctx, chatd.CreateOptions{
OwnerID: apiKey.UserID,
WorkspaceID: workspaceSelection.WorkspaceID,
Title: title,
ModelConfigID: modelConfigID,
SystemPrompt: defaultChatSystemPrompt(),
InitialUserContent: contentBlocks,
ContentFileIDs: contentFileIDs,
OwnerID: apiKey.UserID,
WorkspaceID: workspaceSelection.WorkspaceID,
Title: title,
ModelConfigID: modelConfigID,
SystemPrompt: defaultChatSystemPrompt(),
Content: contentParts,
})
if err != nil {
if database.IsForeignKeyViolation(
@@ -668,7 +666,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
return
}
contentBlocks, contentFileIDs, _, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
contentParts, _, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
@@ -680,11 +678,10 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
sendResult, sendErr := api.chatDaemon.SendMessage(
ctx,
chatd.SendMessageOptions{
ChatID: chatID,
Content: contentBlocks,
ContentFileIDs: contentFileIDs,
ModelConfigID: req.ModelConfigID,
BusyBehavior: chatd.SendMessageBusyBehaviorQueue,
ChatID: chatID,
Content: contentParts,
ModelConfigID: req.ModelConfigID,
BusyBehavior: chatd.SendMessageBusyBehaviorQueue,
},
)
if sendErr != nil {
@@ -743,7 +740,7 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
return
}
contentBlocks, contentFileIDs, _, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
contentParts, _, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
@@ -755,8 +752,7 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
editResult, editErr := api.chatDaemon.EditMessage(ctx, chatd.EditMessageOptions{
ChatID: chat.ID,
EditedMessageID: messageID,
Content: contentBlocks,
ContentFileIDs: contentFileIDs,
Content: contentParts,
})
if editErr != nil {
switch {
@@ -2455,8 +2451,7 @@ func (api *API) chatFileByID(rw http.ResponseWriter, r *http.Request) {
}
func createChatInputFromRequest(ctx context.Context, db database.Store, req codersdk.CreateChatRequest) (
[]fantasy.Content,
map[int]uuid.UUID,
[]codersdk.ChatMessagePart,
string,
*codersdk.Response,
) {
@@ -2468,32 +2463,34 @@ func createChatInputFromParts(
db database.Store,
parts []codersdk.ChatInputPart,
fieldName string,
) ([]fantasy.Content, map[int]uuid.UUID, string, *codersdk.Response) {
) ([]codersdk.ChatMessagePart, string, *codersdk.Response) {
if len(parts) == 0 {
return nil, nil, "", &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Content is required.",
Detail: "Content cannot be empty.",
}
}
content := make([]fantasy.Content, 0, len(parts))
fileIDs := make(map[int]uuid.UUID)
result := make([]codersdk.ChatMessagePart, 0, len(parts))
textParts := make([]string, 0, len(parts))
for i, part := range parts {
switch strings.ToLower(strings.TrimSpace(string(part.Type))) {
case string(codersdk.ChatInputPartTypeText):
text := strings.TrimSpace(part.Text)
if text == "" {
return nil, nil, "", &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].text cannot be empty.", fieldName, i),
}
}
content = append(content, fantasy.TextContent{Text: text})
result = append(result, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeText,
Text: text,
})
textParts = append(textParts, text)
case string(codersdk.ChatInputPartTypeFile):
if part.FileID == uuid.Nil {
return nil, nil, "", &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id is required for file parts.", fieldName, i),
}
@@ -2504,27 +2501,36 @@ func createChatInputFromParts(
chatFile, err := db.GetChatFileByID(ctx, part.FileID)
if err != nil {
if httpapi.Is404Error(err) {
return nil, nil, "", &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id references a file that does not exist.", fieldName, i),
}
}
return nil, nil, "", &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Internal error.",
Detail: fmt.Sprintf("Failed to retrieve file for %s[%d].", fieldName, i),
}
}
content = append(content, fantasy.FileContent{
result = append(result, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeFile,
FileID: uuid.NullUUID{UUID: part.FileID, Valid: true},
MediaType: chatFile.Mimetype,
})
fileIDs[len(content)-1] = part.FileID
case string(codersdk.ChatInputPartTypeFileReference):
if part.FileName == "" {
return nil, nil, "", &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_name cannot be empty for file-reference.", fieldName, i),
}
}
result = append(result, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeFileReference,
FileName: part.FileName,
StartLine: part.StartLine,
EndLine: part.EndLine,
Content: part.Content,
})
// Build text representation for title generation.
lineRange := fmt.Sprintf("%d", part.StartLine)
if part.StartLine != part.EndLine {
lineRange = fmt.Sprintf("%d-%d", part.StartLine, part.EndLine)
@@ -2534,11 +2540,9 @@ func createChatInputFromParts(
if strings.TrimSpace(part.Content) != "" {
_, _ = fmt.Fprintf(&sb, "\n```%s\n%s\n```", part.FileName, strings.TrimSpace(part.Content))
}
text := sb.String()
content = append(content, fantasy.TextContent{Text: text})
textParts = append(textParts, text)
textParts = append(textParts, sb.String())
default:
return nil, nil, "", &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf(
"%s[%d].type %q is not supported.",
@@ -2550,16 +2554,8 @@ func createChatInputFromParts(
}
}
// Allow file-only messages. The titleSource may be empty
// when only file parts are provided, callers handle this.
if len(content) == 0 {
return nil, nil, "", &codersdk.Response{
Message: "Content is required.",
Detail: fmt.Sprintf("%s must include at least one text or file part.", fieldName),
}
}
titleSource := strings.TrimSpace(strings.Join(textParts, " "))
return content, fileIDs, titleSource, nil
return result, titleSource, nil
}
func chatTitleFromMessage(message string) string {
+68 -38
View File
@@ -1564,9 +1564,14 @@ func TestChatMessageWithFileReferences(t *testing.T) {
})
require.NoError(t, err)
// The file-reference is stored as a formatted text block.
wantText := "[file-reference] main.go:10-15\n" +
"```main.go\nfunc broken() {}\n```"
// The file-reference is now stored as a native file-reference part.
checkFileRef := func(part codersdk.ChatMessagePart) bool {
return part.Type == codersdk.ChatMessagePartTypeFileReference &&
part.FileName == "main.go" &&
part.StartLine == 10 &&
part.EndLine == 15 &&
part.Content == "func broken() {}"
}
var found bool
require.Eventually(t, func() bool {
@@ -1579,8 +1584,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
continue
}
for _, part := range message.Content {
if part.Type == codersdk.ChatMessagePartTypeText &&
part.Text == wantText {
if checkFileRef(part) {
found = true
return true
}
@@ -1590,8 +1594,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
if created.Queued && created.QueuedMessage != nil {
for _, queued := range chatWithMessages.QueuedMessages {
for _, part := range queued.Content {
if part.Type == codersdk.ChatMessagePartTypeText &&
part.Text == wantText {
if checkFileRef(part) {
found = true
return true
}
@@ -1600,7 +1603,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
}
return false
}, testutil.WaitLong, testutil.IntervalFast)
require.True(t, found, "expected to find file-reference text in stored message")
require.True(t, found, "expected to find file-reference part in stored message")
})
t.Run("FileReferenceSingleLine", func(t *testing.T) {
@@ -1623,9 +1626,14 @@ func TestChatMessageWithFileReferences(t *testing.T) {
})
require.NoError(t, err)
// Single-line range should use "42" not "42-42".
wantText := "[file-reference] lib/utils.ts:42\n" +
"```lib/utils.ts\nconst x = 1;\n```"
// Single-line range: stored as native file-reference part.
checkFileRef := func(part codersdk.ChatMessagePart) bool {
return part.Type == codersdk.ChatMessagePartTypeFileReference &&
part.FileName == "lib/utils.ts" &&
part.StartLine == 42 &&
part.EndLine == 42 &&
part.Content == "const x = 1;"
}
require.Eventually(t, func() bool {
chatWithMessages, getErr := client.GetChat(ctx, chat.ID)
@@ -1634,7 +1642,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
}
for _, msg := range chatWithMessages.Messages {
for _, part := range msg.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
if checkFileRef(part) {
return true
}
}
@@ -1642,7 +1650,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
if created.Queued && created.QueuedMessage != nil {
for _, queued := range chatWithMessages.QueuedMessages {
for _, part := range queued.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
if checkFileRef(part) {
return true
}
}
@@ -1672,8 +1680,14 @@ func TestChatMessageWithFileReferences(t *testing.T) {
})
require.NoError(t, err)
// No fenced code block when content is empty.
wantText := "[file-reference] README.md:1"
// Stored as native file-reference with empty content.
checkFileRef := func(part codersdk.ChatMessagePart) bool {
return part.Type == codersdk.ChatMessagePartTypeFileReference &&
part.FileName == "README.md" &&
part.StartLine == 1 &&
part.EndLine == 1 &&
part.Content == ""
}
require.Eventually(t, func() bool {
chatWithMessages, getErr := client.GetChat(ctx, chat.ID)
if getErr != nil {
@@ -1681,7 +1695,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
}
for _, msg := range chatWithMessages.Messages {
for _, part := range msg.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
if checkFileRef(part) {
return true
}
}
@@ -1689,7 +1703,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
if created.Queued && created.QueuedMessage != nil {
for _, queued := range chatWithMessages.QueuedMessages {
for _, part := range queued.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
if checkFileRef(part) {
return true
}
}
@@ -1719,8 +1733,13 @@ func TestChatMessageWithFileReferences(t *testing.T) {
})
require.NoError(t, err)
wantText := "[file-reference] server.go:5-8\n" +
"```server.go\nfunc main() {\n\tfmt.Println()\n}\n```"
checkFileRef := func(part codersdk.ChatMessagePart) bool {
return part.Type == codersdk.ChatMessagePartTypeFileReference &&
part.FileName == "server.go" &&
part.StartLine == 5 &&
part.EndLine == 8 &&
part.Content == "func main() {\n\tfmt.Println()\n}"
}
require.Eventually(t, func() bool {
chatWithMessages, getErr := client.GetChat(ctx, chat.ID)
@@ -1729,7 +1748,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
}
for _, msg := range chatWithMessages.Messages {
for _, part := range msg.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
if checkFileRef(part) {
return true
}
}
@@ -1737,7 +1756,7 @@ func TestChatMessageWithFileReferences(t *testing.T) {
if created.Queued && created.QueuedMessage != nil {
for _, queued := range chatWithMessages.QueuedMessages {
for _, part := range queued.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText {
if checkFileRef(part) {
return true
}
}
@@ -1792,14 +1811,21 @@ func TestChatMessageWithFileReferences(t *testing.T) {
})
require.NoError(t, err)
// Verify that all six parts are stored in order.
wantTexts := []string{
"Please review these two issues:",
"[file-reference] a.go:1-3\n```a.go\nline1\nline2\nline3\n```",
"first issue",
"and also:",
"[file-reference] b.go:10\n```b.go\nreturn nil\n```",
"second issue",
// Verify that all six parts are stored in order with
// correct types: text, file-reference, text, text,
// file-reference, text.
type wantPart struct {
partType codersdk.ChatMessagePartType
text string // for text parts
fileName string // for file-reference parts
}
wantParts := []wantPart{
{partType: codersdk.ChatMessagePartTypeText, text: "Please review these two issues:"},
{partType: codersdk.ChatMessagePartTypeFileReference, fileName: "a.go"},
{partType: codersdk.ChatMessagePartTypeText, text: "first issue"},
{partType: codersdk.ChatMessagePartTypeText, text: "and also:"},
{partType: codersdk.ChatMessagePartTypeFileReference, fileName: "b.go"},
{partType: codersdk.ChatMessagePartTypeText, text: "second issue"},
}
require.Eventually(t, func() bool {
@@ -1811,19 +1837,23 @@ func TestChatMessageWithFileReferences(t *testing.T) {
// Check messages and queued messages for the
// interleaved parts in order.
checkParts := func(parts []codersdk.ChatMessagePart) bool {
textParts := make([]string, 0, len(parts))
for _, part := range parts {
if part.Type == codersdk.ChatMessagePartTypeText {
textParts = append(textParts, part.Text)
}
}
if len(textParts) != len(wantTexts) {
if len(parts) != len(wantParts) {
return false
}
for i, want := range wantTexts {
if textParts[i] != want {
for i, want := range wantParts {
if parts[i].Type != want.partType {
return false
}
switch want.partType {
case codersdk.ChatMessagePartTypeText:
if parts[i].Text != want.text {
return false
}
case codersdk.ChatMessagePartTypeFileReference:
if parts[i].FileName != want.fileName {
return false
}
}
}
return true
}
+42 -226
View File
@@ -1136,115 +1136,57 @@ func ChatQueuedMessages(messages []database.ChatQueuedMessage) []codersdk.ChatQu
}
func chatMessageParts(role string, raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
switch role {
case string(fantasy.MessageRoleSystem):
content, err := parseSystemContent(raw)
if err != nil {
return nil, err
// All roles now use chatprompt.ParseContent which handles both
// the new SDK format and legacy fantasy envelope format. For
// tool messages, we also try the legacy toolResultRow format.
parts, err := chatprompt.ParseContent(role, raw)
if err != nil {
// Tool messages may use the legacy [{"tool_call_id":...}]
// format which ParseContent doesn't handle.
if role == string(fantasy.MessageRoleTool) {
return parseToolResultsAsSDKParts(raw)
}
if strings.TrimSpace(content) == "" {
return nil, nil
}
return []codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeText,
Text: content,
}}, nil
case string(fantasy.MessageRoleUser), string(fantasy.MessageRoleAssistant):
content, err := parseContentBlocks(role, raw)
if err != nil {
return nil, err
}
var rawBlocks []json.RawMessage
_ = json.Unmarshal(raw.RawMessage, &rawBlocks)
parts := make([]codersdk.ChatMessagePart, 0, len(content))
for i, block := range content {
part := contentBlockToPart(block)
if part.Type == "" {
continue
}
if i < len(rawBlocks) {
switch part.Type {
case codersdk.ChatMessagePartTypeReasoning:
part.Title = reasoningStoredTitle(rawBlocks[i])
case codersdk.ChatMessagePartTypeFile:
if fid, err := chatprompt.ExtractFileID(rawBlocks[i]); err == nil {
part.FileID = uuid.NullUUID{UUID: fid, Valid: true}
}
// When a file_id is present, omit inline data
// from the response. Clients fetch content via
// the GET /chats/files/{id} endpoint instead.
if part.FileID.Valid {
part.Data = nil
}
}
}
parts = append(parts, part)
}
return parts, nil
case string(fantasy.MessageRoleTool):
results, err := parseToolResults(raw)
if err != nil {
return nil, err
}
parts := make([]codersdk.ChatMessagePart, 0, len(results))
for _, result := range results {
parts = append(parts, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolResult,
ToolCallID: result.ToolCallID,
ToolName: result.ToolName,
Result: result.Result,
IsError: result.IsError,
})
}
return parts, nil
default:
return nil, nil
return nil, err
}
if len(parts) == 0 && role == string(fantasy.MessageRoleTool) {
// ParseContent returns nil for tool messages stored in the
// legacy toolResultRow format. Try that format.
return parseToolResultsAsSDKParts(raw)
}
// Strip provider_metadata and inline file data from API
// responses. Provider metadata is internal; file data is
// fetched via the files endpoint.
for i := range parts {
parts[i].ProviderMetadata = nil
if parts[i].Type == codersdk.ChatMessagePartTypeFile && parts[i].FileID.Valid {
parts[i].Data = nil
}
}
return parts, nil
}
func parseSystemContent(raw pqtype.NullRawMessage) (string, error) {
if !raw.Valid || len(raw.RawMessage) == 0 {
return "", nil
// parseToolResultsAsSDKParts parses tool results stored in the
// legacy [{"tool_call_id":...}] format into SDK parts.
func parseToolResultsAsSDKParts(raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
results, err := parseToolResults(raw)
if err != nil {
return nil, err
}
var content string
if err := json.Unmarshal(raw.RawMessage, &content); err != nil {
return "", xerrors.Errorf("parse system content: %w", err)
parts := make([]codersdk.ChatMessagePart, 0, len(results))
for _, result := range results {
parts = append(parts, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolResult,
ToolCallID: result.ToolCallID,
ToolName: result.ToolName,
Result: result.Result,
IsError: result.IsError,
})
}
return content, nil
return parts, nil
}
func parseContentBlocks(role string, raw pqtype.NullRawMessage) ([]fantasy.Content, error) {
if !raw.Valid || len(raw.RawMessage) == 0 {
return nil, nil
}
if role == string(fantasy.MessageRoleUser) {
var text string
if err := json.Unmarshal(raw.RawMessage, &text); err == nil {
return []fantasy.Content{
fantasy.TextContent{Text: text},
}, nil
}
}
var blocks []json.RawMessage
if err := json.Unmarshal(raw.RawMessage, &blocks); err != nil {
return nil, xerrors.Errorf("parse content blocks: %w", err)
}
content := make([]fantasy.Content, 0, len(blocks))
for _, block := range blocks {
decoded, err := fantasy.UnmarshalContent(block)
if err != nil {
return nil, xerrors.Errorf("parse content block: %w", err)
}
content = append(content, decoded)
}
return content, nil
}
// toolResultRow is used only for extracting top-level fields from
// persisted tool result JSON. The result payload is kept as raw JSON.
@@ -1267,134 +1209,8 @@ func parseToolResults(raw pqtype.NullRawMessage) ([]toolResultRow, error) {
return results, nil
}
func reasoningStoredTitle(raw json.RawMessage) string {
var envelope struct {
Type string `json:"type"`
Data struct {
Title string `json:"title"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return ""
}
if !strings.EqualFold(envelope.Type, string(fantasy.ContentTypeReasoning)) {
return ""
}
return strings.TrimSpace(envelope.Data.Title)
}
func contentBlockToPart(block fantasy.Content) codersdk.ChatMessagePart {
switch value := block.(type) {
case fantasy.TextContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeText,
Text: value.Text,
}
case *fantasy.TextContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeText,
Text: value.Text,
}
case fantasy.ReasoningContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Text: value.Text,
}
case *fantasy.ReasoningContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeReasoning,
Text: value.Text,
}
case fantasy.ToolCallContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
}
case *fantasy.ToolCallContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
}
case fantasy.SourceContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeSource,
SourceID: value.ID,
URL: value.URL,
Title: value.Title,
}
case *fantasy.SourceContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeSource,
SourceID: value.ID,
URL: value.URL,
Title: value.Title,
}
case fantasy.FileContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeFile,
MediaType: value.MediaType,
Data: value.Data,
}
case *fantasy.FileContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeFile,
MediaType: value.MediaType,
Data: value.Data,
}
case fantasy.ToolResultContent:
return chatprompt.ToolResultToPart(
value.ToolCallID,
value.ToolName,
toolResultOutputToRawJSON(value.Result),
toolResultOutputIsError(value.Result),
)
case *fantasy.ToolResultContent:
return chatprompt.ToolResultToPart(
value.ToolCallID,
value.ToolName,
toolResultOutputToRawJSON(value.Result),
toolResultOutputIsError(value.Result),
)
default:
return codersdk.ChatMessagePart{}
}
}
func toolResultOutputToRawJSON(output fantasy.ToolResultOutputContent) json.RawMessage {
switch v := output.(type) {
case fantasy.ToolResultOutputContentError:
if v.Error != nil {
data, _ := json.Marshal(map[string]any{"error": v.Error.Error()})
return data
}
return json.RawMessage(`{"error":""}`)
case fantasy.ToolResultOutputContentText:
raw := json.RawMessage(v.Text)
if json.Valid(raw) {
return raw
}
data, _ := json.Marshal(map[string]any{"output": v.Text})
return data
case fantasy.ToolResultOutputContentMedia:
data, _ := json.Marshal(map[string]any{
"data": v.Data,
"mime_type": v.MediaType,
"text": v.Text,
})
return data
default:
return json.RawMessage(`{}`)
}
}
func toolResultOutputIsError(output fantasy.ToolResultOutputContent) bool {
_, ok := output.(fantasy.ToolResultOutputContentError)
return ok
}
func nullInt64Ptr(v sql.NullInt64) *int64 {
if !v.Valid {
+11 -4
View File
@@ -438,9 +438,12 @@ func TestAIBridgeInterception(t *testing.T) {
}
}
func TestChatMessage_ReasoningPartWithoutPersistedTitleIsEmpty(t *testing.T) {
func TestChatMessage_ReasoningPartLegacyEnvelope(t *testing.T) {
t.Parallel()
// Legacy fantasy envelope format for reasoning content.
// Title extraction has been removed — we only verify the
// reasoning text is preserved.
assistantContent, err := json.Marshal([]fantasy.Content{
fantasy.ReasoningContent{
Text: "Plan migration",
@@ -468,12 +471,14 @@ func TestChatMessage_ReasoningPartWithoutPersistedTitleIsEmpty(t *testing.T) {
require.Len(t, message.Content, 1)
require.Equal(t, codersdk.ChatMessagePartTypeReasoning, message.Content[0].Type)
require.Equal(t, "Plan migration", message.Content[0].Text)
require.Empty(t, message.Content[0].Title)
}
func TestChatMessage_ReasoningPartPrefersPersistedTitle(t *testing.T) {
func TestChatMessage_ReasoningPartLegacyPersistedTitle(t *testing.T) {
t.Parallel()
// Legacy format with a "title" field injected into the
// fantasy envelope. Since title extraction is removed, the
// title field is ignored and only the text is preserved.
reasoningContent, err := json.Marshal(fantasy.ReasoningContent{
Text: "Verify schema updates, then apply changes in order.",
ProviderMetadata: fantasy.ProviderMetadata{
@@ -511,7 +516,9 @@ func TestChatMessage_ReasoningPartPrefersPersistedTitle(t *testing.T) {
require.Len(t, message.Content, 1)
require.Equal(t, codersdk.ChatMessagePartTypeReasoning, message.Content[0].Type)
require.Equal(t, "Persisted stream title", message.Content[0].Title)
require.Equal(t, "Verify schema updates, then apply changes in order.", message.Content[0].Text)
// Title extraction removed — persisted title is not surfaced.
require.Empty(t, message.Content[0].Title)
}
func TestChatQueuedMessage_ParsesUserContentParts(t *testing.T) {
+16
View File
@@ -106,6 +106,14 @@ type ChatMessagePart struct {
EndLine int `json:"end_line,omitempty"`
// The code content from the diff that was commented on.
Content string `json:"content,omitempty"`
// ProviderMetadata stores opaque provider-specific metadata from
// the LLM response. It is persisted for round-trip fidelity when
// replaying conversation history but is NOT sent to API clients.
// This field is stripped in db2sdk before returning to callers.
ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"`
// ProviderExecuted indicates whether a tool call was executed by
// the provider. Only relevant for tool-call parts.
ProviderExecuted bool `json:"provider_executed,omitempty"`
}
// ChatInputPartType represents an input part type for user chat input.
@@ -129,6 +137,14 @@ type ChatInputPart struct {
EndLine int `json:"end_line,omitempty"`
// The code content from the diff that was commented on.
Content string `json:"content,omitempty"`
// ProviderMetadata stores opaque provider-specific metadata from
// the LLM response. It is persisted for round-trip fidelity when
// replaying conversation history but is NOT sent to API clients.
// This field is stripped in db2sdk before returning to callers.
ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"`
// ProviderExecuted indicates whether a tool call was executed by
// the provider. Only relevant for tool-call parts.
ProviderExecuted bool `json:"provider_executed,omitempty"`
}
// CreateChatRequest is the request to create a new chat.
@@ -333,6 +333,8 @@ describe("parseMessageContent", () => {
expect(ref.endLine).toBe(0);
});
it("does not affect markdown when file-reference blocks are present", () => {
const result = parseMessageContent([
{ type: "text", text: "Hello" },