@@ -396,6 +396,8 @@ type DecodedResultPart =
396396 | { kind : "image" ; bytes : Uint8Array ; mimeType : string }
397397 | { kind : "undecodable" } ;
398398
399+ type NormalizedToolResult = { text : string ; isError : boolean } ;
400+
399401/**
400402 * Decode a tool result's parts ONCE. `toolCallStep` may re-serialize a step several times while
401403 * shrinking it to fit blob admission, and decoding base64 on every attempt made that loop
@@ -427,17 +429,24 @@ function toolResultContentItems(
427429 message : OcxToolResultMessage ,
428430 decoded ?: DecodedResultPart [ ] ,
429431 maxImages = Number . POSITIVE_INFINITY ,
432+ normalizedText ?: NormalizedToolResult ,
430433) {
431434 const parts = decoded ?? decodeResultParts ( message ) ;
435+ const textItem = ( text : string ) => [ create ( McpToolResultContentItemSchema , {
436+ content : { case : "text" as const , value : create ( McpTextContentSchema , { text } ) } ,
437+ } ) ] ;
432438 if ( ! parts ) {
433- const raw = typeof message . content === "string" ? message . content : "" ;
439+ const normalized = normalizedText
440+ ?? normalizedToolResult ( message , typeof message . content === "string" ? message . content : "" ) ;
441+ return textItem ( normalized . text ) ;
442+ }
443+ const normalized = normalizedText ?? normalizedDecodedTextResult ( message , parts ) ;
444+ if ( normalized ) {
434445 // #1920/#1866: empty or failure-state Computer Use / node_repl results are
435- // normalized before they reach the native wire (isError is applied in
436- // toolResultPart via normalizedToolResult below).
437- const { text } = normalizedToolResult ( message , raw ) ;
438- return [ create ( McpToolResultContentItemSchema , {
439- content : { case : "text" as const , value : create ( McpTextContentSchema , { text } ) } ,
440- } ) ] ;
446+ // normalized before they reach the native wire. Pure-text part arrays use
447+ // the same newline-joined representation this serializer already emitted;
448+ // image-bearing and undecodable results stay on the lossless part path.
449+ return textItem ( normalized . text ) ;
441450 }
442451 // Images are dropped OLDEST first when the step must shrink: the most recent screenshot is the
443452 // one the model is reasoning about, so it is the last to go.
@@ -505,7 +514,7 @@ function toolResultToText(message: OcxToolResultMessage): string {
505514 * Shared #1920 normalization entry: pure-text results only. Image-bearing or
506515 * encrypted results pass through untouched (their content is not plain text).
507516 */
508- function normalizedToolResult ( message : OcxToolResultMessage , text : string ) : { text : string ; isError : boolean } {
517+ function normalizedToolResult ( message : OcxToolResultMessage , text : string ) : NormalizedToolResult {
509518 if ( message . containsEncryptedContent ) return { text, isError : message . isError } ;
510519 return normalizeCursorToolResultText ( text , {
511520 toolName : message . toolName ,
@@ -514,6 +523,19 @@ function normalizedToolResult(message: OcxToolResultMessage, text: string): { te
514523 } ) ;
515524}
516525
526+ /**
527+ * A content-part result is plain text only when every decoded part is text (the empty array is the
528+ * empty text result). Join it exactly as toolResultContentItems already did, then share the string
529+ * normalization contract. Any image or undecodable part keeps the existing part-preserving path.
530+ */
531+ function normalizedDecodedTextResult (
532+ message : OcxToolResultMessage ,
533+ parts : DecodedResultPart [ ] ,
534+ ) : NormalizedToolResult | undefined {
535+ if ( parts . some ( part => part . kind !== "text" ) ) return undefined ;
536+ return normalizedToolResult ( message , parts . map ( part => part . kind === "text" ? part . text : "" ) . join ( "\n" ) ) ;
537+ }
538+
517539function argBytes ( value : unknown ) : Uint8Array {
518540 try {
519541 return toBinary ( ValueSchema , fromJson ( ValueSchema , value as JsonValue ) ) ;
@@ -568,15 +590,15 @@ function toolCallStep(
568590
569591function toolResultPart ( message : OcxToolResultMessage , decoded ?: DecodedResultPart [ ] , maxImages ?: number ) {
570592 const parts = decoded ?? decodeResultParts ( message ) ;
571- const normalizedIsError = parts
572- ? message . isError
573- : normalizedToolResult ( message , typeof message . content === "string" ? message . content : "" ) . isError ;
593+ const normalized = parts
594+ ? normalizedDecodedTextResult ( message , parts )
595+ : normalizedToolResult ( message , typeof message . content === "string" ? message . content : "" ) ;
574596 return create ( McpToolResultSchema , {
575597 result : {
576598 case : "success" ,
577599 value : create ( McpSuccessSchema , {
578- isError : normalizedIsError ,
579- content : toolResultContentItems ( message , decoded , maxImages ) ,
600+ isError : normalized ?. isError ?? message . isError ,
601+ content : toolResultContentItems ( message , parts , maxImages , normalized ) ,
580602 } ) ,
581603 } ,
582604 } ) ;
0 commit comments