Skip to content

refactor: make ChatMessagePart a discriminated union#23168

Merged
mafredri merged 1 commit into
mainfrom
refactor/chat-message-part-discriminated-union
Mar 18, 2026
Merged

refactor: make ChatMessagePart a discriminated union#23168
mafredri merged 1 commit into
mainfrom
refactor/chat-message-part-discriminated-union

Conversation

@mafredri
Copy link
Copy Markdown
Member

@mafredri mafredri commented Mar 17, 2026

Generate a discriminated union for ChatMessagePart via a guts codegen
mutation instead of the flat interface with 20+ optional fields. Each
variant (ChatTextPart, ChatReasoningPart, ChatToolCallPart,
ChatToolResultPart, ChatFilePart, ChatFileReferencePart,
ChatSourcePart) narrows the type field to a string literal and
includes only the fields relevant to that part type.

Approach

The variant definitions live in codersdk.ChatMessagePartVariants,
right next to the ChatMessagePart struct. Each entry specifies a type
literal and lists required/optional JSON field names. The codegen
mutation in scripts/apitypings/main.go reads this table, copies field
type information from the original interface, and produces per-variant
sub-interfaces plus a union alias.

A test (TestChatMessagePartVariants) validates every field name in
the variant table against actual struct tags via reflection, catching
renames, typos, and stale entries.

Dead fields (signature, result_delta) are excluded from the
generated variants. They remain on the Go struct for backward-compatible
JSON deserialization of stored rows.

Dead code removal

Remove code that handled situations the backend already prevents:

  • normalizeBlockType (backend always sends canonical type strings)
  • Alias case branches ("thinking", "toolcall", "toolresult")
  • Dead field fallbacks (typedBlock.name, typedBlock.id,
    typedBlock.input, typedBlock.output, etc.)
  • result_delta handling in mergeStreamPayload (never set by backend)

Test coverage

Add 10 new tests covering previously-untested paths:

  • args_delta streaming accumulation (P0, the backend's primary
    tool-call streaming mechanism)
  • provider_executed skip logic for tool-call and tool-result
  • source part parsing and streaming with grouping and deduplication

Type error fixes

The union surfaced three issues in existing code:

  • AgentDetailContent.tsx: accessed media_type/file_id without
    narrowing to ChatFilePart. Added a type predicate filter.
  • ConversationTimeline.stories.tsx: used text instead of content
    on a file-reference fixture (wrong field name).
  • AgentDetail.stories.tsx: reasoning story fixture uses a title
    field that only exists at runtime (extracted by the parser via
    asOptionalTitle), not on the generated ChatReasoningPart type.

🤖 This PR was created with the help of AI, and has been reviewed by a human. 🏂🏻

@mafredri mafredri force-pushed the refactor/chat-message-part-discriminated-union branch 3 times, most recently from 0dd5723 to 3c75c37 Compare March 17, 2026 15:22
@mafredri mafredri changed the title refactor(site/src/pages/AgentsPage): make ChatMessagePart a discriminated union refactor: make ChatMessagePart a discriminated union Mar 17, 2026
@mafredri mafredri force-pushed the refactor/chat-message-part-discriminated-union branch 3 times, most recently from d8bfaaf to 3f49522 Compare March 17, 2026 15:51
Comment thread codersdk/chats_test.go
Comment on lines +213 to +214
"signature": "added in #22290, never populated by any code path",
"result_delta": "added in #22290, never populated by any code path",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kylecarbs should we just drop these from ChatMessagePart?

Comment thread site/src/pages/AgentsPage/AgentDetail.stories.tsx Outdated
@mafredri mafredri force-pushed the refactor/chat-message-part-discriminated-union branch from 3f49522 to 9931f72 Compare March 17, 2026 15:54
@mafredri mafredri marked this pull request as ready for review March 17, 2026 15:57
@mafredri mafredri requested a review from Emyrk as a code owner March 17, 2026 15:57
@mafredri mafredri force-pushed the refactor/chat-message-part-discriminated-union branch 3 times, most recently from 7ddf276 to aa3a741 Compare March 17, 2026 16:09
mafredri added a commit that referenced this pull request Mar 17, 2026
… types and dead fields

RenderBlock's file-reference variant used camelCase field names
(fileName, startLine, endLine) while the API sends snake_case. The
parser destructured and renamed every field. The file variant was
defined inline duplicating ChatFilePart.

Replace both inline definitions with direct references to the
generated ChatFilePart and ChatFileReferencePart types. The
file-reference parser case becomes a pass-through. The file case
now constructs a typed object instead of casting Record<string, unknown>.

Remove dead fields confirmed by Kyle:
- title on thinking/reasoning blocks (never populated by backend)
- text on file-reference blocks (abandoned file comment feature)

This simplifies ReasoningDisclosure by removing the disclosure button
path (dead without title) and the title prop. The component now always
renders inline text or a streaming placeholder.

Refs #23168
@DanielleMaywood
Copy link
Copy Markdown
Contributor

I'm fine with the frontend changes but delegating to @Emyrk for final approval as CODEOWNER

mafredri added a commit that referenced this pull request Mar 17, 2026
… types and dead fields

RenderBlock's file-reference variant used camelCase field names
(fileName, startLine, endLine) while the API sends snake_case. The
parser destructured and renamed every field. The file variant was
defined inline duplicating ChatFilePart.

Replace both inline definitions with direct references to the
generated ChatFilePart and ChatFileReferencePart types. The
file-reference parser case becomes a pass-through. The file case
now constructs a typed object instead of casting Record<string, unknown>.

Remove dead fields confirmed by Kyle:
- title on thinking/reasoning blocks (never populated by backend)
- text on file-reference blocks (abandoned file comment feature)

This simplifies ReasoningDisclosure by removing the disclosure button
path (dead without title) and the title prop. The component now always
renders inline text or a streaming placeholder.

Additional cleanup: removed createBlock helper (identical branches
after title removal), joinText parameter (no production caller),
appendParsedTextBlock wrapper (pass-through after title removal),
appendStreamTextBlock alias (no contrasting variant). Added P2
streaming tool-result shape test.

Refs #23168
mafredri added a commit that referenced this pull request Mar 17, 2026
… types and dead fields

RenderBlock's file-reference variant used camelCase field names
(fileName, startLine, endLine) while the API sends snake_case. The
parser destructured and renamed every field. The file variant was
defined inline duplicating ChatFilePart.

Replace both inline definitions with direct references to the
generated ChatFilePart and ChatFileReferencePart types. The
file-reference parser case becomes a pass-through. The file case
now constructs a typed object instead of casting Record<string, unknown>.

Remove dead fields confirmed by Kyle:
- title on thinking/reasoning blocks (never populated by backend)
- text on file-reference blocks (abandoned file comment feature)

This simplifies ReasoningDisclosure by removing the disclosure button
path (dead without title) and the title prop. The component now always
renders inline text or a streaming placeholder.

Additional cleanup: removed createBlock helper (identical branches
after title removal), joinText parameter (no production caller),
appendParsedTextBlock wrapper (pass-through after title removal),
appendStreamTextBlock alias (no contrasting variant). Added P2
streaming tool-result shape test.

Refs #23168
Copy link
Copy Markdown
Member

@Emyrk Emyrk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overall design is fine, and glad to see people using the guts api in the abusive ways that it should be! 😄

I don't see the golang side of this though. Are we doing json marshaling of this type in Go?

Comment thread codersdk/chats.go Outdated
Required: []string{"url"},
Optional: []string{"source_id", "title"},
},
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this used in the golang side of things? Like how are we marshaling to json for the db and unmarshaling?

Copy link
Copy Markdown
Member Author

@mafredri mafredri Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Emyrk It's a simple flat type that we write and read from the database as jsonb, you can see it here:

coder/codersdk/chats.go

Lines 101 to 214 in 6fc9f19

// 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.
type ChatMessagePart struct {
Type ChatMessagePartType `json:"type"`
Text string `json:"text,omitempty"`
Signature string `json:"signature,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Args json.RawMessage `json:"args,omitempty"`
ArgsDelta string `json:"args_delta,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
ResultDelta string `json:"result_delta,omitempty"`
IsError bool `json:"is_error,omitempty"`
SourceID string `json:"source_id,omitempty"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
MediaType string `json:"media_type,omitempty"`
Data []byte `json:"data,omitempty"`
FileID uuid.NullUUID `json:"file_id,omitempty" format:"uuid"`
// The following fields are only set when Type is
// ChatInputPartTypeFileReference.
FileName string `json:"file_name,omitempty"`
StartLine int `json:"start_line,omitempty"`
EndLine int `json:"end_line,omitempty"`
// The code content from the diff that was commented on.
Content string `json:"content,omitempty"`
// ProviderMetadata holds provider-specific response metadata
// (e.g. Anthropic cache control hints) as raw JSON. Internal
// only: stripped by db2sdk before API responses.
ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty" typescript:"-"`
// ProviderExecuted indicates the tool call was executed by
// the provider (e.g. Anthropic computer use).
ProviderExecuted bool `json:"provider_executed,omitempty"`
}
// StripInternal removes internal-only fields that must not be
// sent to API clients. Call before publishing via REST or SSE.
//
// Note: ArgsDelta and ResultDelta are intentionally preserved.
// They are streaming-only fields consumed by the frontend via
// SSE message_part events (see processStepStream in chatloop).
func (p *ChatMessagePart) StripInternal() {
p.ProviderMetadata = nil
if p.FileID.Valid {
p.Data = nil
}
}
// ChatMessageText builds a text chat message part.
func ChatMessageText(text string) ChatMessagePart {
return ChatMessagePart{Type: ChatMessagePartTypeText, Text: text}
}
// ChatMessageReasoning builds a reasoning chat message part.
func ChatMessageReasoning(text string) ChatMessagePart {
return ChatMessagePart{Type: ChatMessagePartTypeReasoning, Text: text}
}
// ChatMessageToolCall builds a tool-call chat message part.
func ChatMessageToolCall(toolCallID, toolName string, args json.RawMessage) ChatMessagePart {
return ChatMessagePart{
Type: ChatMessagePartTypeToolCall,
ToolCallID: toolCallID,
ToolName: toolName,
Args: args,
}
}
// ChatMessageToolResult builds a tool-result chat message part.
func ChatMessageToolResult(toolCallID, toolName string, result json.RawMessage, isError bool) ChatMessagePart {
return ChatMessagePart{
Type: ChatMessagePartTypeToolResult,
ToolCallID: toolCallID,
ToolName: toolName,
Result: result,
IsError: isError,
}
}
// ChatMessageFile builds a file chat message part.
func ChatMessageFile(fileID uuid.UUID, mediaType string) ChatMessagePart {
return ChatMessagePart{
Type: ChatMessagePartTypeFile,
FileID: uuid.NullUUID{UUID: fileID, Valid: true},
MediaType: mediaType,
}
}
// ChatMessageFileReference builds a file-reference chat message part.
func ChatMessageFileReference(fileName string, startLine, endLine int, content string) ChatMessagePart {
return ChatMessagePart{
Type: ChatMessagePartTypeFileReference,
FileName: fileName,
StartLine: startLine,
EndLine: endLine,
Content: content,
}
}
// ChatMessageSource builds a source chat message part.
func ChatMessageSource(sourceID, sourceURL, title string) ChatMessagePart {
return ChatMessagePart{
Type: ChatMessagePartTypeSource,
SourceID: sourceID,
URL: sourceURL,
Title: title,
}
}

I could see us changing this down the line to add more type/field safety, but for now it's what we have. The writes to DB are versioned, so if we want to change it or break it in the future we can do so in a back-compatible manner.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels werid to write the fields like this:

var ChatMessagePartVariants = []ChatMessagePartVariant{
	{
		TypeLiteral: ChatMessagePartTypeText,
		Required:    []string{"text"},
	},
	{
		TypeLiteral: ChatMessagePartTypeReasoning,
		Required:    []string{"text"},
	},
	{
		TypeLiteral: ChatMessagePartTypeToolCall,
		Required:    []string{"tool_call_id", "tool_name"},
		Optional:    []string{"args", "args_delta", "provider_executed"},
	},
	{
		TypeLiteral: ChatMessagePartTypeToolResult,
		Required:    []string{"tool_call_id", "tool_name"},
		Optional:    []string{"result", "is_error", "provider_executed"},
	},
	...

It feels like we are duplicating source of truth. Maybe a another struct tag to reuse the json tag? And use reflect?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Emyrk yeah I know what you mean. I initially prototyped this with using an intermediary type var foo ChatMessagePart and referencing the structure fields field(&foo.Text) where the function used reflect to look up the json tag. While it would work and give some compile-time safety, I'm not too excited about using reflect in codersdk. I'd also like these definitions to exist close to ChatMessagePart. My second solution was this with accompanying test that enforces all fields being referenced, and using valid tags.

Another option I entertained was defining this via struct tags (e.g. Text field belongs/required by types "foo" and "bar", but that quickly becomes hard to read and easy to mess up.

For now this felt like the cleanest approach, given the data type.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would the reflect not only live in the main.go for api typings?

Are these field defintions used in the codersdk? How I understood it is that these field definitions exist for the api typings

Copy link
Copy Markdown
Member Author

@mafredri mafredri Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean moving all the logic to apitypings? (If not, perhaps I'm not fully understanding what you're proposing and an example would be helpful.)

Are these field defintions used in the codersdk?

Not exactly, but I originally had a concern with defining these types fully in the apitypings package as that creates a disconnect with where the type are used/defined (i.e. Go).

Even though these types are weakly defined in Go, (as linked above) e.g.:

func ChatMessageText(text string) ChatMessagePart { 
	return ChatMessagePart{Type: ChatMessagePartTypeText, Text: text} 
}

Let's say we expand this to be:

func ChatMessageText(text, foo string) ChatMessagePart { 
	return ChatMessagePart{Type: ChatMessagePartTypeText, Text: text, Foo: foo} 
}

The author making the change may notice that the TS types are not updating, but they'd have to know to dive into the apitypings package to update the definition, compared to it being in the same file.

Another option would be to actually define these Go types in codersdk, but having them there without refactoring all code to use them would also be a bit awkward, as is the data format since we have []ChatMessagePart and expressing that as []ChatMessageText | ChatMessage... doesn't map cleanly in all situations. It's something I'm actively thinking about how we should improve though.

If you think we should move this logic to apitypings, however, happy to make the change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworked. Variant membership is now declared via variants struct tags directly on ChatMessagePart fields:

type ChatMessagePart struct {
	Type       ChatMessagePartType `json:"type"`
	Text       string              `json:"text,omitempty" variants:"text,reasoning"`
	ToolCallID string              `json:"tool_call_id,omitempty" variants:"tool-call,tool-result"`
	Args       json.RawMessage     `json:"args,omitempty" variants:"tool-call?"`
	// ...
}

Bare name = required, ? suffix = optional. Fields without a variants tag are excluded from the union.

The codegen mutation in apitypings/main.go reads these tags via reflect and builds the union from them. ChatMessagePartVariant type and ChatMessagePartVariants slice are removed from codersdk. The test validates every field either has a variants tag or is in an explicit exclusion list.

@mafredri mafredri force-pushed the refactor/chat-message-part-discriminated-union branch 3 times, most recently from 78468a8 to 677c711 Compare March 18, 2026 09:03
…ated union

Generate a discriminated union for ChatMessagePart via a guts codegen
mutation instead of the flat interface with 20+ optional fields. Each
variant (ChatTextPart, ChatReasoningPart, ChatToolCallPart,
ChatToolResultPart, ChatFilePart, ChatFileReferencePart, ChatSourcePart)
narrows the type field to a string literal and includes only relevant
fields. Dead fields (signature, result_delta) are excluded.

Remove dead code that handled situations the backend prevents:
normalizeBlockType (backend sends canonical types), alias case branches
("thinking", "toolcall", "toolresult"), dead field fallbacks
(typedBlock.name, typedBlock.id, typedBlock.input, typedBlock.output),
and result_delta handling in mergeStreamPayload.

Add test coverage for args_delta streaming (the primary tool-call
streaming mechanism, previously untested), provider_executed skip logic,
and source part parsing/streaming with grouping and deduplication.

Fix type errors surfaced by the union: add type narrowing filter in
AgentDetailContent.tsx, correct story fixture field names, cast
reasoning story data that uses frontend-only title field.
@mafredri mafredri force-pushed the refactor/chat-message-part-discriminated-union branch from 677c711 to 08421e5 Compare March 18, 2026 09:17
@mafredri mafredri enabled auto-merge (squash) March 18, 2026 09:24
@mafredri mafredri merged commit 66f8093 into main Mar 18, 2026
28 checks passed
@mafredri mafredri deleted the refactor/chat-message-part-discriminated-union branch March 18, 2026 09:27
@github-actions github-actions Bot locked and limited conversation to collaborators Mar 18, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants