mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user