@@ -879,6 +879,339 @@ describe("convert-from-opencode-events", () => {
879879 } ) ;
880880 } ) ;
881881
882+ describe ( "StructuredOutput tool parts" , ( ) => {
883+ it ( "should skip empty input during pending StructuredOutput" , ( ) => {
884+ const state = createStreamState ( ) ;
885+ const event : EventMessagePartUpdated = {
886+ type : "message.part.updated" ,
887+ properties : {
888+ part : {
889+ id : "part-1" ,
890+ sessionID : "session-123" ,
891+ messageID : "msg-1" ,
892+ type : "tool" ,
893+ callID : "call-so-1" ,
894+ tool : "StructuredOutput" ,
895+ state : {
896+ status : "pending" ,
897+ input : { } ,
898+ raw : '{}' ,
899+ } ,
900+ } as ToolPart ,
901+ } ,
902+ } ;
903+
904+ const parts = convertEventToStreamParts ( event , state ) ;
905+
906+ expect ( parts ) . toHaveLength ( 0 ) ;
907+ expect ( state . textStarted ) . toBe ( false ) ;
908+ } ) ;
909+
910+ it ( "should emit text-start and text-delta for pending StructuredOutput with data" , ( ) => {
911+ const state = createStreamState ( ) ;
912+ const event : EventMessagePartUpdated = {
913+ type : "message.part.updated" ,
914+ properties : {
915+ part : {
916+ id : "part-1" ,
917+ sessionID : "session-123" ,
918+ messageID : "msg-1" ,
919+ type : "tool" ,
920+ callID : "call-so-1" ,
921+ tool : "StructuredOutput" ,
922+ state : {
923+ status : "pending" ,
924+ input : { output : "partial" , outputType : "markdown" } ,
925+ raw : '{"output":"partial","outputType":"markdown"}' ,
926+ } ,
927+ } as ToolPart ,
928+ } ,
929+ } ;
930+
931+ const parts = convertEventToStreamParts ( event , state ) ;
932+
933+ expect ( parts [ 0 ] ) . toMatchObject ( { type : "text-start" , id : "structured-output-call-so-1" } ) ;
934+ expect ( parts [ 1 ] ) . toMatchObject ( {
935+ type : "text-delta" ,
936+ id : "structured-output-call-so-1" ,
937+ delta : JSON . stringify ( { output : "partial" , outputType : "markdown" } ) ,
938+ } ) ;
939+ expect ( parts . some ( ( p ) => p . type === "tool-input-start" ) ) . toBe ( false ) ;
940+ } ) ;
941+
942+ it ( "should emit incremental text-delta for running StructuredOutput" , ( ) => {
943+ const state = createStreamState ( ) ;
944+
945+ const makeEvent = ( input : Record < string , unknown > ) : EventMessagePartUpdated => ( {
946+ type : "message.part.updated" ,
947+ properties : {
948+ part : {
949+ id : "part-1" ,
950+ sessionID : "session-123" ,
951+ messageID : "msg-1" ,
952+ type : "tool" ,
953+ callID : "call-so-1" ,
954+ tool : "StructuredOutput" ,
955+ state : {
956+ status : "running" ,
957+ input,
958+ time : { start : 1000 } ,
959+ } ,
960+ } as ToolPart ,
961+ } ,
962+ } ) ;
963+
964+ // First running event
965+ const parts1 = convertEventToStreamParts (
966+ makeEvent ( { output : "hel" } ) ,
967+ state ,
968+ ) ;
969+ const deltas1 = parts1 . filter ( ( p ) => p . type === "text-delta" ) ;
970+ expect ( deltas1 ) . toHaveLength ( 1 ) ;
971+ expect ( ( deltas1 [ 0 ] as any ) . delta ) . toBe ( JSON . stringify ( { output : "hel" } ) ) ;
972+
973+ // Same input — no delta
974+ const parts2 = convertEventToStreamParts (
975+ makeEvent ( { output : "hel" } ) ,
976+ state ,
977+ ) ;
978+ const deltas2 = parts2 . filter ( ( p ) => p . type === "text-delta" ) ;
979+ expect ( deltas2 ) . toHaveLength ( 0 ) ;
980+
981+ // Extended input — only the diff
982+ const parts3 = convertEventToStreamParts (
983+ makeEvent ( { output : "hello world" } ) ,
984+ state ,
985+ ) ;
986+ const deltas3 = parts3 . filter ( ( p ) => p . type === "text-delta" ) ;
987+ expect ( deltas3 ) . toHaveLength ( 1 ) ;
988+ } ) ;
989+
990+ it ( "should emit text-end on completed StructuredOutput" , ( ) => {
991+ const state = createStreamState ( ) ;
992+ const structuredInput = { output : "# Result" , outputType : "markdown" } ;
993+
994+ const event : EventMessagePartUpdated = {
995+ type : "message.part.updated" ,
996+ properties : {
997+ part : {
998+ id : "part-1" ,
999+ sessionID : "session-123" ,
1000+ messageID : "msg-1" ,
1001+ type : "tool" ,
1002+ callID : "call-so-1" ,
1003+ tool : "StructuredOutput" ,
1004+ state : {
1005+ status : "completed" ,
1006+ input : structuredInput ,
1007+ output : "Structured output captured successfully." ,
1008+ title : "Structured Output" ,
1009+ time : { start : 1000 , end : 2000 } ,
1010+ } ,
1011+ } as ToolPart ,
1012+ } ,
1013+ } ;
1014+
1015+ const parts = convertEventToStreamParts ( event , state ) ;
1016+
1017+ expect ( parts . some ( ( p ) => p . type === "text-start" ) ) . toBe ( true ) ;
1018+ expect ( parts . some ( ( p ) => p . type === "text-delta" ) ) . toBe ( true ) ;
1019+ expect ( parts . some ( ( p ) => p . type === "text-end" ) ) . toBe ( true ) ;
1020+
1021+ const delta = parts . find ( ( p ) => p . type === "text-delta" ) as { delta ?: string } | undefined ;
1022+ expect ( JSON . parse ( delta ! . delta ! ) ) . toEqual ( structuredInput ) ;
1023+
1024+ // No tool-call or tool-result parts
1025+ expect ( parts . some ( ( p ) => p . type === "tool-call" ) ) . toBe ( false ) ;
1026+ expect ( parts . some ( ( p ) => p . type === "tool-result" ) ) . toBe ( false ) ;
1027+ expect ( parts . some ( ( p ) => p . type === "tool-input-start" ) ) . toBe ( false ) ;
1028+ } ) ;
1029+
1030+ it ( "should stream text across pending → running → completed lifecycle" , ( ) => {
1031+ const state = createStreamState ( ) ;
1032+ const callID = "call-so-lifecycle" ;
1033+
1034+ const makeEvent = (
1035+ status : string ,
1036+ input : Record < string , unknown > ,
1037+ ) : EventMessagePartUpdated => ( {
1038+ type : "message.part.updated" ,
1039+ properties : {
1040+ part : {
1041+ id : "part-1" ,
1042+ sessionID : "session-123" ,
1043+ messageID : "msg-1" ,
1044+ type : "tool" ,
1045+ callID,
1046+ tool : "StructuredOutput" ,
1047+ state : {
1048+ status,
1049+ input,
1050+ ...( status === "pending" ? { raw : JSON . stringify ( input ) } : { } ) ,
1051+ ...( status === "running" ? { time : { start : 1000 } } : { } ) ,
1052+ ...( status === "completed"
1053+ ? {
1054+ output : "Structured output captured successfully." ,
1055+ title : "Structured Output" ,
1056+ time : { start : 1000 , end : 2000 } ,
1057+ }
1058+ : { } ) ,
1059+ } ,
1060+ } as ToolPart ,
1061+ } ,
1062+ } ) ;
1063+
1064+ // pending with empty input (real OpenCode behavior)
1065+ const parts0 = convertEventToStreamParts (
1066+ makeEvent ( "pending" , { } ) ,
1067+ state ,
1068+ ) ;
1069+ expect ( parts0 ) . toHaveLength ( 0 ) ;
1070+
1071+ // running with real input — text starts here
1072+ const parts1 = convertEventToStreamParts (
1073+ makeEvent ( "running" , { output : "hello" } ) ,
1074+ state ,
1075+ ) ;
1076+ expect ( parts1 [ 0 ] ) . toMatchObject ( { type : "text-start" } ) ;
1077+ const deltas1 = parts1 . filter ( ( p ) => p . type === "text-delta" ) ;
1078+ expect ( deltas1 ) . toHaveLength ( 1 ) ;
1079+ expect ( JSON . parse ( ( deltas1 [ 0 ] as any ) . delta ) ) . toEqual ( { output : "hello" } ) ;
1080+
1081+ // completed with final input
1082+ const parts2 = convertEventToStreamParts (
1083+ makeEvent ( "completed" , { output : "hello world" , outputType : "markdown" } ) ,
1084+ state ,
1085+ ) ;
1086+ expect ( parts2 . some ( ( p ) => p . type === "text-delta" ) ) . toBe ( true ) ;
1087+ expect ( parts2 . some ( ( p ) => p . type === "text-end" ) ) . toBe ( true ) ;
1088+
1089+ // State should be closed
1090+ expect ( state . textStarted ) . toBe ( false ) ;
1091+ expect ( state . textPartId ) . toBeUndefined ( ) ;
1092+ } ) ;
1093+
1094+ it ( "should emit {} for completed StructuredOutput with empty object output" , ( ) => {
1095+ const state = createStreamState ( ) ;
1096+ const event : EventMessagePartUpdated = {
1097+ type : "message.part.updated" ,
1098+ properties : {
1099+ part : {
1100+ id : "part-1" ,
1101+ sessionID : "session-123" ,
1102+ messageID : "msg-1" ,
1103+ type : "tool" ,
1104+ callID : "call-so-1" ,
1105+ tool : "StructuredOutput" ,
1106+ state : {
1107+ status : "completed" ,
1108+ input : { } ,
1109+ output : "Structured output captured successfully." ,
1110+ title : "Structured Output" ,
1111+ time : { start : 1000 , end : 2000 } ,
1112+ } ,
1113+ } as ToolPart ,
1114+ } ,
1115+ } ;
1116+
1117+ const parts = convertEventToStreamParts ( event , state ) ;
1118+
1119+ const delta = parts . find ( ( p ) => p . type === "text-delta" ) as { delta ?: string } | undefined ;
1120+ expect ( delta ) . toBeDefined ( ) ;
1121+ expect ( delta ! . delta ) . toBe ( "{}" ) ;
1122+ } ) ;
1123+
1124+ it ( "should close text part on StructuredOutput error" , ( ) => {
1125+ const state = createStreamState ( ) ;
1126+
1127+ // First, get a text-start via a running event with real input
1128+ const runningEvent : EventMessagePartUpdated = {
1129+ type : "message.part.updated" ,
1130+ properties : {
1131+ part : {
1132+ id : "part-1" ,
1133+ sessionID : "session-123" ,
1134+ messageID : "msg-1" ,
1135+ type : "tool" ,
1136+ callID : "call-so-1" ,
1137+ tool : "StructuredOutput" ,
1138+ state : {
1139+ status : "running" ,
1140+ input : { output : "partial" } ,
1141+ time : { start : 1000 } ,
1142+ } ,
1143+ } as ToolPart ,
1144+ } ,
1145+ } ;
1146+ convertEventToStreamParts ( runningEvent , state ) ;
1147+ expect ( state . textStarted ) . toBe ( true ) ;
1148+
1149+ // Now error
1150+ const errorEvent : EventMessagePartUpdated = {
1151+ type : "message.part.updated" ,
1152+ properties : {
1153+ part : {
1154+ id : "part-1" ,
1155+ sessionID : "session-123" ,
1156+ messageID : "msg-1" ,
1157+ type : "tool" ,
1158+ callID : "call-so-1" ,
1159+ tool : "StructuredOutput" ,
1160+ state : {
1161+ status : "error" ,
1162+ input : { output : "partial" } ,
1163+ error : "Model did not produce structured output" ,
1164+ time : { start : 1000 , end : 2000 } ,
1165+ } ,
1166+ } as ToolPart ,
1167+ } ,
1168+ } ;
1169+
1170+ const parts = convertEventToStreamParts ( errorEvent , state ) ;
1171+
1172+ expect ( parts ) . toEqual ( [
1173+ { type : "text-end" , id : "structured-output-call-so-1" } ,
1174+ ] ) ;
1175+ expect ( state . textStarted ) . toBe ( false ) ;
1176+ expect ( state . textPartId ) . toBeUndefined ( ) ;
1177+ } ) ;
1178+
1179+ it ( "should close prior text part when StructuredOutput starts" , ( ) => {
1180+ const state = createStreamState ( ) ;
1181+ // Simulate an existing text part being open
1182+ state . textStarted = true ;
1183+ state . textPartId = "existing-text-1" ;
1184+ state . lastTextContent = "some text" ;
1185+
1186+ const event : EventMessagePartUpdated = {
1187+ type : "message.part.updated" ,
1188+ properties : {
1189+ part : {
1190+ id : "part-1" ,
1191+ sessionID : "session-123" ,
1192+ messageID : "msg-1" ,
1193+ type : "tool" ,
1194+ callID : "call-so-1" ,
1195+ tool : "StructuredOutput" ,
1196+ state : {
1197+ status : "completed" ,
1198+ input : { result : "done" } ,
1199+ output : "ok" ,
1200+ title : "Structured Output" ,
1201+ time : { start : 1000 , end : 2000 } ,
1202+ } ,
1203+ } as ToolPart ,
1204+ } ,
1205+ } ;
1206+
1207+ const parts = convertEventToStreamParts ( event , state ) ;
1208+
1209+ // Should close the previous text part first
1210+ expect ( parts [ 0 ] ) . toMatchObject ( { type : "text-end" , id : "existing-text-1" } ) ;
1211+ expect ( parts [ 1 ] ) . toMatchObject ( { type : "text-start" , id : "structured-output-call-so-1" } ) ;
1212+ } ) ;
1213+ } ) ;
1214+
8821215 describe ( "step-finish parts" , ( ) => {
8831216 it ( "should accumulate usage from step-finish" , ( ) => {
8841217 const state = createStreamState ( ) ;
0 commit comments