@@ -46,8 +46,12 @@ type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInpu
4646const OpenAIResponsesOutputText = Schema . Struct ( {
4747 type : Schema . tag ( "output_text" ) ,
4848 text : Schema . String ,
49+ annotations : Schema . Array ( Schema . Unknown ) ,
4950} )
5051
52+ const OpenAIResponsesMessagePhase = Schema . Literals ( [ "commentary" , "final_answer" ] )
53+ type OpenAIResponsesMessagePhase = Schema . Schema . Type < typeof OpenAIResponsesMessagePhase >
54+
5155const OpenAIResponsesReasoningSummaryText = Schema . Struct ( {
5256 type : Schema . tag ( "summary_text" ) ,
5357 text : Schema . String ,
@@ -78,7 +82,19 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([
7882const OpenAIResponsesInputItem = Schema . Union ( [
7983 Schema . Struct ( { role : Schema . tag ( "system" ) , content : Schema . String } ) ,
8084 Schema . Struct ( { role : Schema . tag ( "user" ) , content : Schema . Array ( OpenAIResponsesInputContent ) } ) ,
81- Schema . Struct ( { role : Schema . tag ( "assistant" ) , content : Schema . Array ( OpenAIResponsesOutputText ) } ) ,
85+ Schema . Struct ( {
86+ role : Schema . tag ( "assistant" ) ,
87+ content : Schema . String ,
88+ phase : optionalNull ( OpenAIResponsesMessagePhase ) ,
89+ } ) ,
90+ Schema . Struct ( {
91+ type : Schema . tag ( "message" ) ,
92+ id : Schema . String ,
93+ status : Schema . Literals ( [ "in_progress" , "completed" , "incomplete" ] ) ,
94+ role : Schema . tag ( "assistant" ) ,
95+ content : Schema . Array ( OpenAIResponsesOutputText ) ,
96+ phase : optionalNull ( OpenAIResponsesMessagePhase ) ,
97+ } ) ,
8298 OpenAIResponsesReasoningItem ,
8399 OpenAIResponsesItemReference ,
84100 Schema . Struct ( {
@@ -194,7 +210,9 @@ const OpenAIResponsesStreamItem = Schema.Struct({
194210 server_label : Schema . optional ( Schema . String ) ,
195211 output : Schema . optional ( Schema . Unknown ) ,
196212 error : Schema . optional ( Schema . Unknown ) ,
213+ content : Schema . optional ( Schema . Array ( Schema . Unknown ) ) ,
197214 encrypted_content : optionalNull ( Schema . String ) ,
215+ phase : optionalNull ( OpenAIResponsesMessagePhase ) ,
198216} )
199217type OpenAIResponsesStreamItem = Schema . Schema . Type < typeof OpenAIResponsesStreamItem >
200218
@@ -212,7 +230,9 @@ const OpenAIResponsesErrorPayload = Schema.Struct({
212230const OpenAIResponsesEvent = Schema . Struct ( {
213231 type : Schema . String ,
214232 delta : Schema . optional ( Schema . String ) ,
233+ text : Schema . optional ( Schema . String ) ,
215234 item_id : Schema . optional ( Schema . String ) ,
235+ content_index : Schema . optional ( Schema . Number ) ,
216236 summary_index : Schema . optional ( Schema . Number ) ,
217237 item : Schema . optional ( OpenAIResponsesStreamItem ) ,
218238 response : Schema . optional (
@@ -237,10 +257,18 @@ interface ParserState {
237257 readonly tools : ToolStream . State < string >
238258 readonly hasFunctionCall : boolean
239259 readonly lifecycle : Lifecycle . State
260+ readonly messageItems : Readonly < Record < string , MessageStreamItem > >
261+ readonly messageContentIDs : ReadonlySet < string >
262+ readonly nextMessageContentID : number
240263 readonly reasoningItems : Readonly < Record < string , ReasoningStreamItem > >
241264 readonly store : boolean | undefined
242265}
243266
267+ interface MessageStreamItem {
268+ readonly providerMetadata ?: ProviderMetadata
269+ readonly content : Readonly < Record < number , { readonly id : string ; readonly text : string } > >
270+ }
271+
244272type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
245273
246274interface ReasoningStreamItem {
@@ -298,6 +326,26 @@ const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | un
298326 }
299327}
300328
329+ const messagePhase = ( part : TextPart ) : OpenAIResponsesMessagePhase | null | undefined => {
330+ const phase = part . providerMetadata ?. openai ?. phase
331+ return phase === "commentary" || phase === "final_answer" || phase === null ? phase : undefined
332+ }
333+
334+ const messageItemID = ( part : TextPart ) => {
335+ const itemID = part . providerMetadata ?. openai ?. itemId
336+ return typeof itemID === "string" && itemID . length > 0 ? itemID : undefined
337+ }
338+
339+ const messageStatus = ( part : TextPart ) => {
340+ const status = part . providerMetadata ?. openai ?. status
341+ return status === "in_progress" || status === "completed" || status === "incomplete" ? status : undefined
342+ }
343+
344+ const messageAnnotations = ( part : TextPart ) => {
345+ const annotations = part . providerMetadata ?. openai ?. annotations
346+ return Array . isArray ( annotations ) ? annotations : [ ]
347+ }
348+
301349const hostedToolItemID = ( part : ToolResultPart ) => {
302350 const openai = part . providerMetadata ?. openai
303351 return ProviderShared . isRecord ( openai ) && typeof openai . itemId === "string" && openai . itemId . length > 0
@@ -368,17 +416,49 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
368416 }
369417
370418 if ( message . role === "assistant" ) {
419+ const inputStart = input . length
371420 const content : TextPart [ ] = [ ]
421+ let phase : OpenAIResponsesMessagePhase | null | undefined
422+ let itemID : string | undefined
423+ let status : "in_progress" | "completed" | "incomplete" | undefined
372424 const reasoningItems : Record < string , OpenAIResponsesReasoningReplay > = { }
373425 const reasoningReferences = new Set < string > ( )
374426 const hostedToolReferences = new Set < string > ( )
375427 const flushText = ( ) => {
376428 if ( content . length === 0 ) return
377- input . push ( { role : "assistant" , content : content . map ( ( part ) => ( { type : "output_text" , text : part . text } ) ) } )
429+ input . push (
430+ itemID
431+ ? {
432+ type : "message" ,
433+ id : itemID ,
434+ status : status ?? "completed" ,
435+ role : "assistant" ,
436+ content : content . map ( ( part ) => ( {
437+ type : "output_text" ,
438+ text : part . text ,
439+ annotations : messageAnnotations ( part ) ,
440+ } ) ) ,
441+ ...( phase !== undefined ? { phase } : { } ) ,
442+ }
443+ : {
444+ role : "assistant" ,
445+ content : ProviderShared . joinText ( content ) ,
446+ ...( phase !== undefined ? { phase } : { } ) ,
447+ } ,
448+ )
378449 content . splice ( 0 , content . length )
450+ phase = undefined
451+ itemID = undefined
452+ status = undefined
379453 }
380454 for ( const part of message . content ) {
381455 if ( part . type === "text" ) {
456+ const nextPhase = messagePhase ( part )
457+ const nextItemID = messageItemID ( part )
458+ if ( content . length > 0 && ( phase !== nextPhase || itemID !== nextItemID ) ) flushText ( )
459+ phase = nextPhase
460+ itemID = nextItemID
461+ status = messageStatus ( part ) ?? status
382462 content . push ( part )
383463 continue
384464 }
@@ -429,6 +509,20 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
429509 ] )
430510 }
431511 flushText ( )
512+ if ( store === false && Object . values ( reasoningItems ) . some ( ( item ) => typeof item . encrypted_content !== "string" ) )
513+ input . splice (
514+ inputStart ,
515+ input . length - inputStart ,
516+ ...input . slice ( inputStart ) . map ( ( item ) =>
517+ "type" in item && item . type === "message"
518+ ? {
519+ role : "assistant" as const ,
520+ content : ProviderShared . joinText ( item . content ) ,
521+ ...( item . phase !== undefined ? { phase : item . phase } : { } ) ,
522+ }
523+ : item ,
524+ ) ,
525+ )
432526 continue
433527 }
434528
@@ -612,15 +706,134 @@ const NO_EVENTS: StepResult["1"] = []
612706// the protocol's `terminal` predicate stay in sync.
613707const TERMINAL_TYPES = new Set ( [ "response.completed" , "response.incomplete" , "response.failed" ] )
614708
615- const onOutputTextDelta = ( state : ParserState , event : OpenAIResponsesEvent ) : StepResult => {
616- if ( ! event . delta ) return [ state , NO_EVENTS ]
709+ const messageMetadata = ( item : OpenAIResponsesStreamItem , id : string , previous ?: ProviderMetadata ) => {
710+ const openai = previous ?. openai
711+ const phase = item . phase !== undefined ? item . phase : openai ?. phase
712+ const status =
713+ item . status === "in_progress" || item . status === "completed" || item . status === "incomplete"
714+ ? item . status
715+ : openai ?. status
716+ return openaiMetadata ( {
717+ itemId : id ,
718+ ...( phase === "commentary" || phase === "final_answer" || phase === null ? { phase } : { } ) ,
719+ ...( status === "in_progress" || status === "completed" || status === "incomplete" ? { status } : { } ) ,
720+ } )
721+ }
722+
723+ const messageContentMetadata = (
724+ providerMetadata : ProviderMetadata ,
725+ item : OpenAIResponsesStreamItem ,
726+ index : number ,
727+ ) : ProviderMetadata => {
728+ const content = item . content ?. [ index ]
729+ if ( ! ProviderShared . isRecord ( content ) || content . type !== "output_text" || ! Array . isArray ( content . annotations ) )
730+ return providerMetadata
731+ return openaiMetadata ( { ...providerMetadata . openai , annotations : content . annotations } )
732+ }
733+
734+ const ensureMessageContent = ( state : ParserState , event : OpenAIResponsesEvent ) => {
735+ const itemID = event . item_id ?? "text-0"
736+ const index = event . content_index ?? 0
737+ const item = state . messageItems [ itemID ] ?? { content : { } }
738+ const existing = item . content [ index ]
739+ if ( existing ) return { state, itemID, index, item, content : existing }
740+ const findID = ( next : number ) : readonly [ string , number ] => {
741+ const id = `openai-text-${ next } `
742+ return state . messageContentIDs . has ( id ) ? findID ( next + 1 ) : [ id , next + 1 ]
743+ }
744+ const [ id , nextMessageContentID ] =
745+ index === 0 && ! state . messageContentIDs . has ( itemID )
746+ ? ( [ itemID , state . nextMessageContentID ] as const )
747+ : findID ( state . nextMessageContentID )
748+ const content = { id, text : "" }
749+ const nextItem = { ...item , content : { ...item . content , [ index ] : content } }
750+ return {
751+ state : {
752+ ...state ,
753+ messageItems : { ...state . messageItems , [ itemID ] : nextItem } ,
754+ messageContentIDs : new Set ( [ ...state . messageContentIDs , id ] ) ,
755+ nextMessageContentID,
756+ } ,
757+ itemID,
758+ index,
759+ item : nextItem ,
760+ content,
761+ }
762+ }
763+
764+ const updateMessageContent = (
765+ state : ParserState ,
766+ itemID : string ,
767+ index : number ,
768+ content : { readonly id : string ; readonly text : string } ,
769+ ) : ParserState => ( {
770+ ...state ,
771+ messageItems : {
772+ ...state . messageItems ,
773+ [ itemID ] : {
774+ ...state . messageItems [ itemID ] ,
775+ content : { ...state . messageItems [ itemID ] ?. content , [ index ] : content } ,
776+ } ,
777+ } ,
778+ } )
779+
780+ const closeOtherMessageContent = ( state : ParserState , events : LLMEvent [ ] , item : MessageStreamItem , index : number ) =>
781+ Object . entries ( item . content ) . reduce (
782+ ( lifecycle , entry ) =>
783+ Number ( entry [ 0 ] ) === index ? lifecycle : Lifecycle . textEnd ( lifecycle , events , entry [ 1 ] . id , item . providerMetadata ) ,
784+ state . lifecycle ,
785+ )
786+
787+ const appendOutputText = ( state : ParserState , event : OpenAIResponsesEvent , text : string ) : StepResult => {
788+ const ensured = ensureMessageContent ( state , event )
617789 const events : LLMEvent [ ] = [ ]
790+ const lifecycle = Lifecycle . textStart (
791+ closeOtherMessageContent ( ensured . state , events , ensured . item , ensured . index ) ,
792+ events ,
793+ ensured . content . id ,
794+ ensured . item . providerMetadata ,
795+ )
618796 return [
619- { ...state , lifecycle : Lifecycle . textDelta ( state . lifecycle , events , event . item_id ?? "text-0" , event . delta ) } ,
797+ {
798+ ...updateMessageContent ( ensured . state , ensured . itemID , ensured . index , {
799+ ...ensured . content ,
800+ text : ensured . content . text + text ,
801+ } ) ,
802+ lifecycle : Lifecycle . textDelta ( lifecycle , events , ensured . content . id , text ) ,
803+ } ,
620804 events ,
621805 ]
622806}
623807
808+ const onOutputTextDelta = ( state : ParserState , event : OpenAIResponsesEvent ) : StepResult => {
809+ if ( ! event . delta ) return [ state , NO_EVENTS ]
810+ return appendOutputText ( state , event , event . delta )
811+ }
812+
813+ const onOutputTextDone = ( state : ParserState , event : OpenAIResponsesEvent ) : StepResult => {
814+ if ( event . text === undefined ) return [ state , NO_EVENTS ]
815+ const ensured = ensureMessageContent ( state , event )
816+ if ( event . text === ensured . content . text ) {
817+ if ( ensured . state . lifecycle . text . has ( ensured . content . id ) ) return [ ensured . state , NO_EVENTS ]
818+ const events : LLMEvent [ ] = [ ]
819+ return [
820+ {
821+ ...ensured . state ,
822+ lifecycle : Lifecycle . textStart (
823+ closeOtherMessageContent ( ensured . state , events , ensured . item , ensured . index ) ,
824+ events ,
825+ ensured . content . id ,
826+ ensured . item . providerMetadata ,
827+ ) ,
828+ } ,
829+ events ,
830+ ]
831+ }
832+ if ( event . text . startsWith ( ensured . content . text ) )
833+ return appendOutputText ( ensured . state , event , event . text . slice ( ensured . content . text . length ) )
834+ return [ ensured . state , NO_EVENTS ]
835+ }
836+
624837const onReasoningDelta = ( state : ParserState , event : OpenAIResponsesEvent ) : StepResult => {
625838 if ( ! event . delta ) return [ state , NO_EVENTS ]
626839 const events : LLMEvent [ ] = [ ]
@@ -655,6 +868,23 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
655868// best-effort, not guaranteed.
656869const onOutputItemAdded = ( state : ParserState , event : OpenAIResponsesEvent ) : StepResult => {
657870 const item = event . item
871+ if ( item ?. type === "message" && item . id ) {
872+ const existing = state . messageItems [ item . id ]
873+ return [
874+ {
875+ ...state ,
876+ messageItems : {
877+ ...state . messageItems ,
878+ [ item . id ] : {
879+ ...existing ,
880+ providerMetadata : messageMetadata ( item , item . id , existing ?. providerMetadata ) ,
881+ content : existing ?. content ?? { } ,
882+ } ,
883+ } ,
884+ } ,
885+ NO_EVENTS ,
886+ ]
887+ }
658888 if ( item && isReasoningItem ( item ) ) {
659889 const events : LLMEvent [ ] = [ ]
660890 return [
@@ -812,6 +1042,32 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
8121042 const item = event . item
8131043 if ( ! item ) return [ state , NO_EVENTS ] satisfies StepResult
8141044
1045+ if ( item . type === "message" && item . id ) {
1046+ const events : LLMEvent [ ] = [ ]
1047+ const itemID = item . id
1048+ const messageItem = state . messageItems [ itemID ]
1049+ const { [ itemID ] : _finished , ...messageItems } = state . messageItems
1050+ const providerMetadata = messageMetadata ( item , itemID , messageItem ?. providerMetadata )
1051+ const lifecycle = Object . entries ( messageItem ?. content ?? { } ) . reduce (
1052+ ( lifecycle , entry ) =>
1053+ Lifecycle . textEnd (
1054+ lifecycle ,
1055+ events ,
1056+ entry [ 1 ] . id ,
1057+ messageContentMetadata ( providerMetadata , item , Number ( entry [ 0 ] ) ) ,
1058+ ) ,
1059+ state . lifecycle ,
1060+ )
1061+ return [
1062+ {
1063+ ...state ,
1064+ lifecycle,
1065+ messageItems,
1066+ } ,
1067+ events ,
1068+ ] satisfies StepResult
1069+ }
1070+
8151071 if ( item . type === "function_call" ) {
8161072 if ( ! item . id || ! item . call_id || ! item . name ) return [ state , NO_EVENTS ] satisfies StepResult
8171073 const tools = state . tools [ item . id ]
@@ -939,6 +1195,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
9391195 if ( event . type === "response.reasoning_summary_part.done" )
9401196 return Effect . succeed ( onReasoningSummaryPartDone ( state , event ) )
9411197 if ( event . type === "response.output_item.added" ) return Effect . succeed ( onOutputItemAdded ( state , event ) )
1198+ if ( event . type === "response.output_text.done" ) return Effect . succeed ( onOutputTextDone ( state , event ) )
9421199 if ( event . type === "response.function_call_arguments.delta" ) return onFunctionCallArgumentsDelta ( state , event )
9431200 if ( event . type === "response.output_item.done" ) return onOutputItemDone ( state , event )
9441201 if ( event . type === "response.completed" || event . type === "response.incomplete" )
@@ -968,6 +1225,9 @@ export const protocol = Protocol.make({
9681225 hasFunctionCall : false ,
9691226 tools : ToolStream . empty < string > ( ) ,
9701227 lifecycle : Lifecycle . initial ( ) ,
1228+ messageItems : { } ,
1229+ messageContentIDs : new Set < string > ( ) ,
1230+ nextMessageContentID : 0 ,
9711231 reasoningItems : { } ,
9721232 store : OpenAIOptions . store ( request ) ,
9731233 } ) ,
0 commit comments