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
+24 -3
View File
@@ -1158,7 +1158,7 @@ export interface ChatMessage {
readonly created_by?: string;
readonly model_config_id?: string;
readonly created_at: string;
readonly role: string;
readonly role: ChatMessageRole;
readonly content?: readonly ChatMessagePart[];
readonly usage?: ChatMessageUsage;
}
@@ -1166,6 +1166,13 @@ export interface ChatMessage {
// From codersdk/chats.go
/**
* ChatMessagePart is a structured chunk of a chat message.
*
* WARNING: This type is both an API wire type and a database
* persistence format. Its JSON layout is stored in the
* chat_messages.content column. Field additions, renames, type
* changes, and omitempty behavior all affect backward-compatible
* deserialization of stored rows. Treat changes to this struct
* with the same care as a database migration.
*/
export interface ChatMessagePart {
readonly type: ChatMessagePartType;
@@ -1178,7 +1185,6 @@ export interface ChatMessagePart {
readonly result?: Record<string, string>;
readonly result_delta?: string;
readonly is_error?: boolean;
readonly provider_executed?: boolean;
readonly source_id?: string;
readonly url?: string;
readonly title?: string;
@@ -1196,6 +1202,11 @@ export interface ChatMessagePart {
* The code content from the diff that was commented on.
*/
readonly content?: string;
/**
* ProviderExecuted indicates the tool call was executed by
* the provider (e.g. Anthropic computer use).
*/
readonly provider_executed?: boolean;
}
// From codersdk/chats.go
@@ -1218,6 +1229,16 @@ export const ChatMessagePartTypes: ChatMessagePartType[] = [
"tool-result",
];
// From codersdk/chats.go
export type ChatMessageRole = "assistant" | "system" | "tool" | "user";
export const ChatMessageRoles: ChatMessageRole[] = [
"assistant",
"system",
"tool",
"user",
];
// From codersdk/chats.go
/**
* ChatMessageUsage contains token usage information for a chat message.
@@ -1599,7 +1620,7 @@ export const ChatStreamEventTypes: ChatStreamEventType[] = [
* ChatStreamMessagePart is a streamed message part update.
*/
export interface ChatStreamMessagePart {
readonly role?: string;
readonly role?: ChatMessageRole;
readonly part: ChatMessagePart;
}
@@ -194,7 +194,7 @@ const makeChat = (chatID: string): TypesGen.Chat => ({
const makeMessage = (
chatID: string,
id: number,
role: string,
role: TypesGen.ChatMessageRole,
text: string,
): TypesGen.ChatMessage => ({
id,