Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f3fc8a13f | |||
| be755176c6 |
+60
-72
@@ -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
@@ -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
@@ -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{}{}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user