feat(coderd/chatd): unify chat storage on SDK parts and fix file-reference rendering (#22958)

File-reference parts in user messages were flattened to `TextContent` at
write time because fantasy has no file-reference content type. The
frontend never saw them as structured parts.

This moves all write paths (user, assistant, tool) from fantasy envelope
format to `codersdk.ChatMessagePart`. The streaming layer (`chatloop`)
is untouched, the conversion happens at the serialization boundary in
`persistStep`.

Old rows are still readable. `ParseContent` uses a structural heuristic
(`isFantasyEnvelopeFormat`) to distinguish legacy envelopes from SDK
parts. We chose this over try/fallback because fantasy envelopes
partially unmarshal into `ChatMessagePart` (the `type` field matches)
while silently losing content. A guard test enforces that no SDK part
can produce the envelope shape.

This is forward-only: new rows are unreadable by old code. Chat is
behind a feature flag so rollback risk is contained.

Also adds a typed `ChatMessageRole` to replace raw strings and
`fantasy.MessageRole*` casts at the persistence boundary. The type
covers `ChatMessage.Role`, `ChatStreamMessagePart.Role`, the
`PublishMessagePart` callback chain, and all DB write sites.
`fantasy.MessageRole*` remains only where we build `fantasy.Message`
structs for LLM dispatch.

Separately, `ProviderMetadata` was leaking to SSE clients via
`publishMessagePart`. `StripInternal` now runs on both the SSE and REST
paths, covering this.

Other cleanup:

- Old `db2sdk.contentBlockToPart` silently dropped metadata on
text/reasoning/tool-call content. New code preserves it.
- `providerMetadataToOptions` now logs warnings instead of silently
returning nil.
- `db2sdk` shrinks from ~250 lines of parallel conversion to ~15 lines
delegating to `chatprompt.ParseContent()`, removing the `fantasy` import
entirely.

Refs #22821
This commit is contained in:
Mathias Fredriksson
2026-03-13 17:53:26 +02:00
committed by GitHub
parent 870583224d
commit bdbcd3428b
19 changed files with 1734 additions and 912 deletions
+19 -20
View File
@@ -10,7 +10,6 @@ import (
"testing"
"time"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
@@ -118,7 +117,7 @@ func TestSubscribeRelayReconnectsOnDrop(t *testing.T) {
Type: codersdk.ChatStreamEventTypeMessagePart,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: "assistant",
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "first-relay"},
Part: codersdk.ChatMessageText("first-relay"),
},
}
close(ch)
@@ -128,7 +127,7 @@ func TestSubscribeRelayReconnectsOnDrop(t *testing.T) {
Type: codersdk.ChatStreamEventTypeMessagePart,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: "assistant",
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "second-relay"},
Part: codersdk.ChatMessageText("second-relay"),
},
}
// Don't close — keep alive so the subscriber stays connected.
@@ -152,7 +151,7 @@ func TestSubscribeRelayReconnectsOnDrop(t *testing.T) {
OwnerID: user.ID,
Title: "relay-reconnect",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
@@ -246,7 +245,7 @@ func TestSubscribeRelayAsyncDoesNotBlock(t *testing.T) {
OwnerID: user.ID,
Title: "relay-async-nonblock",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
@@ -320,14 +319,14 @@ func TestSubscribeRelaySnapshotDelivered(t *testing.T) {
Type: codersdk.ChatStreamEventTypeMessagePart,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: "assistant",
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "snap-one"},
Part: codersdk.ChatMessageText("snap-one"),
},
},
{
Type: codersdk.ChatStreamEventTypeMessagePart,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: "assistant",
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "snap-two"},
Part: codersdk.ChatMessageText("snap-two"),
},
},
}
@@ -337,7 +336,7 @@ func TestSubscribeRelaySnapshotDelivered(t *testing.T) {
Type: codersdk.ChatStreamEventTypeMessagePart,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: "assistant",
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "live-part"},
Part: codersdk.ChatMessageText("live-part"),
},
}
return snapshot, ch, func() {}, nil
@@ -353,7 +352,7 @@ func TestSubscribeRelaySnapshotDelivered(t *testing.T) {
OwnerID: user.ID,
Title: "relay-snapshot",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
@@ -440,7 +439,7 @@ func TestSubscribeRelayStaleDialDiscardedAfterInterrupt(t *testing.T) {
Type: codersdk.ChatStreamEventTypeMessagePart,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: "assistant",
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "stale-part"},
Part: codersdk.ChatMessageText("stale-part"),
},
}
close(ch)
@@ -451,7 +450,7 @@ func TestSubscribeRelayStaleDialDiscardedAfterInterrupt(t *testing.T) {
Type: codersdk.ChatStreamEventTypeMessagePart,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: "assistant",
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "new-worker-part"},
Part: codersdk.ChatMessageText("new-worker-part"),
},
}
return nil, ch, func() {}, nil
@@ -466,7 +465,7 @@ func TestSubscribeRelayStaleDialDiscardedAfterInterrupt(t *testing.T) {
OwnerID: user.ID,
Title: "stale-dial-test",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
@@ -629,7 +628,7 @@ func TestSubscribeCancelDuringInFlightDial(t *testing.T) {
OwnerID: user.ID,
Title: "cancel-inflight-dial",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
@@ -712,7 +711,7 @@ func TestSubscribeRelayRunningToRunningSwitch(t *testing.T) {
Type: codersdk.ChatStreamEventTypeMessagePart,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: "assistant",
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "worker-b-part"},
Part: codersdk.ChatMessageText("worker-b-part"),
},
}
return nil, ch, func() {}, nil
@@ -727,7 +726,7 @@ func TestSubscribeRelayRunningToRunningSwitch(t *testing.T) {
OwnerID: user.ID,
Title: "running-to-running",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
@@ -827,7 +826,7 @@ func TestSubscribeRelayFailedDialRetries(t *testing.T) {
Type: codersdk.ChatStreamEventTypeMessagePart,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: "assistant",
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "retry-success"},
Part: codersdk.ChatMessageText("retry-success"),
},
}
return nil, ch, func() {}, nil
@@ -849,7 +848,7 @@ func TestSubscribeRelayFailedDialRetries(t *testing.T) {
OwnerID: user.ID,
Title: "failed-dial-retry",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
@@ -940,7 +939,7 @@ func TestSubscribeRunningLocalWorkerClosesRelay(t *testing.T) {
Type: codersdk.ChatStreamEventTypeMessagePart,
MessagePart: &codersdk.ChatStreamMessagePart{
Role: "assistant",
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "remote-part"},
Part: codersdk.ChatMessageText("remote-part"),
},
}
// Keep channel open so the relay stays active.
@@ -959,7 +958,7 @@ func TestSubscribeRunningLocalWorkerClosesRelay(t *testing.T) {
OwnerID: user.ID,
Title: "local-worker-closes-relay",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
@@ -1067,7 +1066,7 @@ func TestSubscribeRelayMultipleReconnects(t *testing.T) {
OwnerID: user.ID,
Title: "multiple-reconnects",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
+9 -9
View File
@@ -153,14 +153,14 @@ func TestChatStreamRelay(t *testing.T) {
firstChunkText := "relay-part-one"
streamingChunks <- chattest.OpenAITextChunks(firstChunkText)[0]
firstEvent := waitForStreamTextPart(ctx, t, firstEvents, firstChunkText)
require.Equal(t, "assistant", firstEvent.MessagePart.Role)
require.Equal(t, codersdk.ChatMessageRoleAssistant, firstEvent.MessagePart.Role)
secondEvents, secondStream, err := relayClient.StreamChat(ctx, chat.ID, nil)
require.NoError(t, err)
defer secondStream.Close()
secondSnapshotEvent := waitForStreamTextPart(ctx, t, secondEvents, firstChunkText)
require.Equal(t, "assistant", secondSnapshotEvent.MessagePart.Role)
require.Equal(t, codersdk.ChatMessageRoleAssistant, secondSnapshotEvent.MessagePart.Role)
secondChunkText := "relay-part-two"
streamingChunks <- chattest.OpenAITextChunks(secondChunkText)[0]
@@ -344,7 +344,7 @@ func TestChatStreamRelay(t *testing.T) {
firstChunkText := "tls-relay-part-one"
streamingChunks <- chattest.OpenAITextChunks(firstChunkText)[0]
firstEvent := waitForStreamTextPart(ctx, t, firstEvents, firstChunkText)
require.Equal(t, "assistant", firstEvent.MessagePart.Role)
require.Equal(t, codersdk.ChatMessageRoleAssistant, firstEvent.MessagePart.Role)
// Subscribe from the non-worker replica. This triggers the
// relay dial to the worker over TLS. With the bug, this
@@ -357,7 +357,7 @@ func TestChatStreamRelay(t *testing.T) {
// The relay should deliver the already-sent chunk as a
// snapshot event.
secondSnapshotEvent := waitForStreamTextPart(ctx, t, secondEvents, firstChunkText)
require.Equal(t, "assistant", secondSnapshotEvent.MessagePart.Role)
require.Equal(t, codersdk.ChatMessageRoleAssistant, secondSnapshotEvent.MessagePart.Role)
// Send another chunk and verify it flows through the relay.
secondChunkText := "tls-relay-part-two"
@@ -512,7 +512,7 @@ func TestChatStreamRelay(t *testing.T) {
firstChunkText := "cookie-relay-part-one"
streamingChunks <- chattest.OpenAITextChunks(firstChunkText)[0]
firstEvent := waitForStreamTextPart(ctx, t, firstEvents, firstChunkText)
require.Equal(t, "assistant", firstEvent.MessagePart.Role)
require.Equal(t, codersdk.ChatMessageRoleAssistant, firstEvent.MessagePart.Role)
// Subscribe from the non-worker replica with cookie-only
// auth. This triggers the relay dial. If the relay doesn't
@@ -522,7 +522,7 @@ func TestChatStreamRelay(t *testing.T) {
defer secondStream.Close()
secondSnapshotEvent := waitForStreamTextPart(ctx, t, secondEvents, firstChunkText)
require.Equal(t, "assistant", secondSnapshotEvent.MessagePart.Role)
require.Equal(t, codersdk.ChatMessageRoleAssistant, secondSnapshotEvent.MessagePart.Role)
secondChunkText := "cookie-relay-part-two"
streamingChunks <- chattest.OpenAITextChunks(secondChunkText)[0]
@@ -684,7 +684,7 @@ func TestChatStreamRelay(t *testing.T) {
firstChunkText := "hostprefix-relay-part-one"
streamingChunks <- chattest.OpenAITextChunks(firstChunkText)[0]
firstEvent := waitForStreamTextPart(ctx, t, firstEvents, firstChunkText)
require.Equal(t, "assistant", firstEvent.MessagePart.Role)
require.Equal(t, codersdk.ChatMessageRoleAssistant, firstEvent.MessagePart.Role)
// This subscribe triggers the relay. With the bug, the
// worker replica's HTTPCookies.Middleware strips the bare
@@ -695,7 +695,7 @@ func TestChatStreamRelay(t *testing.T) {
defer secondStream.Close()
secondSnapshotEvent := waitForStreamTextPart(ctx, t, secondEvents, firstChunkText)
require.Equal(t, "assistant", secondSnapshotEvent.MessagePart.Role)
require.Equal(t, codersdk.ChatMessageRoleAssistant, secondSnapshotEvent.MessagePart.Role)
secondChunkText := "hostprefix-relay-part-two"
streamingChunks <- chattest.OpenAITextChunks(secondChunkText)[0]
@@ -854,7 +854,7 @@ func TestChatStreamRelay(t *testing.T) {
// Verify every buffered part arrives on the relay subscriber.
for _, text := range bufferedTexts {
event := waitForStreamTextPart(ctx, t, relayEvents, text)
require.Equal(t, "assistant", event.MessagePart.Role)
require.Equal(t, codersdk.ChatMessageRoleAssistant, event.MessagePart.Role)
}
// Send one more chunk after the relay subscriber is connected