Three PRs in a row have added or touched a server-side result type and missed its counterpart on the message side. Nothing enforces the relationship, and the failure always lands outside the code that was changed.
The three lists that have to stay in sync
For the family of server-side tool call results, three things belong together:
Result\XResult, what a bridge's ResultConverter produces
Message\Content\X, what an assistant message can carry
- the matching arm in
Message::toContent()
Nine pairs are currently in sync: CodeExecution, ComputerCall, ExecutableCode, FileSearch, LocalShellCall, McpApprovalRequest, McpCall, McpListTools, WebSearch, plus Text, Thinking and ToolCall.
One is not: CustomToolCallResult from #2382 has neither a content class nor an arm.
Why it keeps slipping through
The gap never shows up where the change is made. The converter is green, the bridge suite is green, and it only breaks once the result becomes an assistant message, which happens in Chat::submit() or on a tool round-trip, so in application code rather than in the bridge's own tests.
What a test must not do
Requiring every Result class to be mapped would be wrong. VectorResult, ObjectResult, BinaryResult, RerankingResult and ChoiceResult are legitimately not assistant content: embeddings, structured output, images, alternatives. The reverse holds too, Image, Audio, Document, Video and File are input content for user messages and have no result counterpart by design.
The asymmetry to guard is narrower than "these lists must be equal".
Two ways to enforce it
A marker interface, implemented by every result that can appear inside an assistant message, with a test iterating its implementations and asserting toContent() handles each. Explicit, but it touches every existing result class.
An allow-list test, iterating Result\* and requiring each class to be either handled by toContent() or named in an explicit list of non-content results. Cheaper, and the allow-list is the valuable part: adding a new result type then forces a conscious decision instead of being silently forgotten.
Either way the test belongs next to Message::toContent(), not in a bridge.
Three PRs in a row have added or touched a server-side result type and missed its counterpart on the message side. Nothing enforces the relationship, and the failure always lands outside the code that was changed.
The three lists that have to stay in sync
For the family of server-side tool call results, three things belong together:
Result\XResult, what a bridge'sResultConverterproducesMessage\Content\X, what an assistant message can carryMessage::toContent()Nine pairs are currently in sync:
CodeExecution,ComputerCall,ExecutableCode,FileSearch,LocalShellCall,McpApprovalRequest,McpCall,McpListTools,WebSearch, plusText,ThinkingandToolCall.One is not:
CustomToolCallResultfrom #2382 has neither a content class nor an arm.Why it keeps slipping through
The gap never shows up where the change is made. The converter is green, the bridge suite is green, and it only breaks once the result becomes an assistant message, which happens in
Chat::submit()or on a tool round-trip, so in application code rather than in the bridge's own tests.web_searchwas enabled on the request side while the converter had no arm for the blocks it produces, so the search results were silently droppedCustomToolCallResultforcustom_tool_callitems #2382:CustomToolCallResultexists but cannot become part of a message, soMessage::ofAssistant()throwsUnsupported assistant message part of type "..."What a test must not do
Requiring every
Resultclass to be mapped would be wrong.VectorResult,ObjectResult,BinaryResult,RerankingResultandChoiceResultare legitimately not assistant content: embeddings, structured output, images, alternatives. The reverse holds too,Image,Audio,Document,VideoandFileare input content for user messages and have no result counterpart by design.The asymmetry to guard is narrower than "these lists must be equal".
Two ways to enforce it
A marker interface, implemented by every result that can appear inside an assistant message, with a test iterating its implementations and asserting
toContent()handles each. Explicit, but it touches every existing result class.An allow-list test, iterating
Result\*and requiring each class to be either handled bytoContent()or named in an explicit list of non-content results. Cheaper, and the allow-list is the valuable part: adding a new result type then forces a conscious decision instead of being silently forgotten.Either way the test belongs next to
Message::toContent(), not in a bridge.