@@ -21,6 +21,7 @@ import { dispatchStreamEvent } from './dispatch-stream-event'
2121import { createStreamLoopContext , type StreamLoopContext } from './stream-context'
2222import { makeStreamLoopDeps , ref } from './stream-test-helpers'
2323import type { ToolNode } from './turn-model'
24+ import { contentBlocksToModel , modelToContentBlocks } from './turn-model-serialize'
2425
2526let seq = 0
2627function toolEnv ( payload : Record < string , unknown > ) : PersistedStreamEventEnvelope {
@@ -124,3 +125,127 @@ describe('tool events (dispatch → model + side effects)', () => {
124125 expect ( toolNode ( ctx , 'wf-1' ) . status ) . toBe ( 'success' )
125126 } )
126127} )
128+
129+ describe ( 'integration gateway (full wire sequence → published snapshot)' , ( ) => {
130+ const GATEWAY = 'call_integration_tool'
131+ const CALL_ID = 'ig-1'
132+
133+ const generating = ( ) =>
134+ toolEnv ( {
135+ phase : 'call' ,
136+ executor : 'go' ,
137+ mode : 'sync' ,
138+ toolCallId : CALL_ID ,
139+ toolName : GATEWAY ,
140+ status : 'generating' ,
141+ } )
142+
143+ const argsDelta = ( argumentsDelta : string ) =>
144+ toolEnv ( {
145+ phase : 'args_delta' ,
146+ executor : 'go' ,
147+ mode : 'sync' ,
148+ toolCallId : CALL_ID ,
149+ toolName : GATEWAY ,
150+ argumentsDelta,
151+ } )
152+
153+ const gatewayFinalCall = ( ) =>
154+ toolEnv ( {
155+ phase : 'call' ,
156+ executor : 'go' ,
157+ mode : 'sync' ,
158+ toolCallId : CALL_ID ,
159+ toolName : GATEWAY ,
160+ arguments : {
161+ toolId : 'gmail_read_v2' ,
162+ description : 'Read recent emails' ,
163+ arguments : { maxResults : 5 } ,
164+ } ,
165+ } )
166+
167+ const resolvedOperationCall = ( ) =>
168+ toolEnv ( {
169+ phase : 'call' ,
170+ executor : 'sim' ,
171+ mode : 'async' ,
172+ toolCallId : CALL_ID ,
173+ toolName : 'gmail_read_v2' ,
174+ arguments : { maxResults : 5 , credentialId : 'cred-1' } ,
175+ } )
176+
177+ /** The exact toolCall snapshot the browser publishes for this row. */
178+ function publishedToolCall ( ctx : StreamLoopContext ) {
179+ const blocks = modelToContentBlocks ( ctx . state . model )
180+ const block = blocks . find ( ( b ) => b . type === 'tool_call' && b . toolCall ?. id === CALL_ID )
181+ expect ( block ?. toolCall ) . toBeDefined ( )
182+ return block ! . toolCall !
183+ }
184+
185+ it ( 'brands the row from streamed args while generating, then rebinds to the resolved operation' , ( ) => {
186+ const ctx = createStreamLoopContext ( makeStreamLoopDeps ( ) )
187+
188+ // Provisional frame: neutral label, never the humanized gateway name.
189+ dispatchStreamEvent ( ctx , generating ( ) )
190+ expect ( publishedToolCall ( ctx ) . displayTitle ) . toBe ( 'Calling integration' )
191+
192+ // toolId alone brands only the icon (row component); text stays neutral.
193+ dispatchStreamEvent ( ctx , argsDelta ( '{"toolId":"gmail_read_v2",' ) )
194+ expect ( publishedToolCall ( ctx ) . displayTitle ) . toBe ( 'Calling integration' )
195+ expect ( publishedToolCall ( ctx ) . streamingArgs ) . toContain ( '"toolId":"gmail_read_v2"' )
196+
197+ // The model-authored activity phrase becomes the row text as it completes.
198+ dispatchStreamEvent ( ctx , argsDelta ( '"description":"Read recent emails",' ) )
199+ expect ( publishedToolCall ( ctx ) . displayTitle ) . toBe ( 'Read recent emails' )
200+
201+ dispatchStreamEvent ( ctx , argsDelta ( '"arguments":{"maxResults":5}}' ) )
202+ dispatchStreamEvent ( ctx , gatewayFinalCall ( ) )
203+ expect ( publishedToolCall ( ctx ) ) . toEqual (
204+ expect . objectContaining ( {
205+ name : GATEWAY ,
206+ displayTitle : 'Read recent emails' ,
207+ } )
208+ )
209+
210+ // Second authoritative frame (same call id): rebind to the exact operation.
211+ dispatchStreamEvent ( ctx , resolvedOperationCall ( ) )
212+ const rebound = publishedToolCall ( ctx )
213+ expect ( rebound ) . toEqual (
214+ expect . objectContaining ( {
215+ name : 'gmail_read_v2' ,
216+ displayTitle : 'Read recent emails' ,
217+ integrationDescription : 'Read recent emails' ,
218+ params : { maxResults : 5 , credentialId : 'cred-1' } ,
219+ } )
220+ )
221+ expect ( rebound . streamingArgs ) . toBeUndefined ( )
222+
223+ dispatchStreamEvent ( ctx , toolResult ( CALL_ID , true , 'gmail_read_v2' ) )
224+ expect ( publishedToolCall ( ctx ) ) . toEqual (
225+ expect . objectContaining ( {
226+ name : 'gmail_read_v2' ,
227+ status : 'success' ,
228+ displayTitle : 'Read recent emails' ,
229+ } )
230+ )
231+ } )
232+
233+ it ( 'keeps the rebound branding across a snapshot rebuild (reconnect round-trip)' , ( ) => {
234+ const ctx = createStreamLoopContext ( makeStreamLoopDeps ( ) )
235+ dispatchStreamEvent ( ctx , generating ( ) )
236+ dispatchStreamEvent ( ctx , gatewayFinalCall ( ) )
237+ dispatchStreamEvent ( ctx , resolvedOperationCall ( ) )
238+ dispatchStreamEvent ( ctx , toolResult ( CALL_ID , true , 'gmail_read_v2' ) )
239+
240+ const rebuilt = contentBlocksToModel ( modelToContentBlocks ( ctx . state . model ) )
241+ const blocks = modelToContentBlocks ( rebuilt )
242+ const block = blocks . find ( ( b ) => b . type === 'tool_call' && b . toolCall ?. id === CALL_ID )
243+ expect ( block ?. toolCall ) . toEqual (
244+ expect . objectContaining ( {
245+ name : 'gmail_read_v2' ,
246+ status : 'success' ,
247+ displayTitle : 'Read recent emails' ,
248+ } )
249+ )
250+ } )
251+ } )
0 commit comments