@@ -272,3 +272,105 @@ def test_generate_a2ui_schema_tool_is_removed(self):
272272 tool_node = _builder .nodes ["tools" ].runnable
273273 tool_names = list (tool_node .tools_by_name .keys ())
274274 assert "generate_a2ui_schema" not in tool_names
275+
276+
277+ import json
278+ from uuid import uuid4
279+
280+
281+ class TestEmitInPlaceCoalescing :
282+ """Regression: emit_generated_surface MUST coalesce the GenUI turn
283+ into a single AI message (3-message thread, not 4), preserving the
284+ upstream tool-call AI's id, tool_calls, additional_kwargs, and
285+ response_metadata. Envelopes inside the wrapped content MUST be
286+ ordered surfaceUpdate -> beginRendering -> dataModelUpdate × N."""
287+
288+ def _run (self , state ):
289+ from src .graph import emit_generated_surface
290+ return asyncio .run (emit_generated_surface (state ))
291+
292+ def test_post_emit_thread_has_three_messages_not_four (self ):
293+ original_ai_id = str (uuid4 ())
294+ tool_call_id = "call_123"
295+ envelopes = [
296+ {"dataModelUpdate" : {"surfaceId" : "s1" , "contents" : [{"key" : "name" , "valueString" : "Ada" }]}},
297+ {"surfaceUpdate" : {"surfaceId" : "s1" , "components" : [{"id" : "c1" , "component" : {"TextField" : {"value" : "{$.name}" }}}]}},
298+ {"beginRendering" : {"surfaceId" : "s1" , "root" : "c1" }},
299+ ]
300+ tool_call_ai = AIMessage (
301+ id = original_ai_id ,
302+ content = "" ,
303+ tool_calls = [{"id" : tool_call_id , "name" : "render_a2ui_surface" , "args" : {}, "type" : "tool_call" }],
304+ )
305+ tool_msg = ToolMessage (
306+ id = "tool_msg_1" ,
307+ tool_call_id = tool_call_id ,
308+ content = json .dumps (envelopes ),
309+ )
310+ state = {"messages" : [HumanMessage (content = "render a card" ), tool_call_ai , tool_msg ]}
311+
312+ result = self ._run (state )
313+
314+ # add_messages will REPLACE the tool message (same id) and the
315+ # AI message (same id) — net thread length stays 3 after merge.
316+ # Here we just assert the returned message list is 2 entries
317+ # (replacements only), both targeting the upstream ids.
318+ returned = result ["messages" ]
319+ assert len (returned ) == 2 , f"expected 2 replacements, got { len (returned )} : { returned } "
320+ # ToolMessage replacement keeps its id and tool_call_id
321+ tool_replacement = next (m for m in returned if isinstance (m , ToolMessage ))
322+ assert tool_replacement .id == tool_msg .id
323+ assert tool_replacement .tool_call_id == tool_call_id
324+ # AI replacement keeps the upstream AI id (in-place merge)
325+ ai_replacement = next (m for m in returned if isinstance (m , AIMessage ))
326+ assert ai_replacement .id == original_ai_id , (
327+ "AI replacement must reuse upstream tool-call AI id for in-place merge"
328+ )
329+
330+ def test_preserves_tool_calls_additional_kwargs_response_metadata (self ):
331+ original_ai_id = str (uuid4 ())
332+ tool_call_id = "call_xyz"
333+ envelopes = [
334+ {"surfaceUpdate" : {"surfaceId" : "s1" , "components" : []}},
335+ {"beginRendering" : {"surfaceId" : "s1" , "root" : "c1" }},
336+ ]
337+ tool_call_ai = AIMessage (
338+ id = original_ai_id ,
339+ content = "" ,
340+ tool_calls = [{"id" : tool_call_id , "name" : "render_a2ui_surface" , "args" : {}, "type" : "tool_call" }],
341+ additional_kwargs = {"reasoning" : "the user wants a card" },
342+ response_metadata = {"finish_reason" : "tool_calls" },
343+ )
344+ tool_msg = ToolMessage (id = "t1" , tool_call_id = tool_call_id , content = json .dumps (envelopes ))
345+ state = {"messages" : [HumanMessage (content = "x" ), tool_call_ai , tool_msg ]}
346+
347+ result = self ._run (state )
348+ ai_replacement = next (m for m in result ["messages" ] if isinstance (m , AIMessage ))
349+ assert ai_replacement .tool_calls and ai_replacement .tool_calls [0 ]["id" ] == tool_call_id
350+ assert ai_replacement .additional_kwargs .get ("reasoning" ) == "the user wants a card"
351+ assert ai_replacement .response_metadata .get ("finish_reason" ) == "tool_calls"
352+
353+ def test_envelopes_reordered_to_surface_begin_data (self ):
354+ tool_call_id = "call_r"
355+ envelopes_unordered = [
356+ {"dataModelUpdate" : {"surfaceId" : "s1" , "contents" : [{"key" : "n" , "valueString" : "1" }]}},
357+ {"dataModelUpdate" : {"surfaceId" : "s1" , "contents" : [{"key" : "m" , "valueString" : "2" }]}},
358+ {"beginRendering" : {"surfaceId" : "s1" , "root" : "c1" }},
359+ {"surfaceUpdate" : {"surfaceId" : "s1" , "components" : []}},
360+ ]
361+ tool_call_ai = AIMessage (
362+ id = "ai-1" ,
363+ content = "" ,
364+ tool_calls = [{"id" : tool_call_id , "name" : "render_a2ui_surface" , "args" : {}, "type" : "tool_call" }],
365+ )
366+ tool_msg = ToolMessage (id = "t-1" , tool_call_id = tool_call_id , content = json .dumps (envelopes_unordered ))
367+ state = {"messages" : [HumanMessage (content = "x" ), tool_call_ai , tool_msg ]}
368+
369+ result = self ._run (state )
370+ ai = next (m for m in result ["messages" ] if isinstance (m , AIMessage ))
371+ # Strip the A2UI_PREFIX wrapper before splitting JSONL.
372+ lines = [ln for ln in ai .content .split ("\n " ) if ln .strip () and not ln .startswith ("---a2ui_JSON---" )]
373+ keys = [list (json .loads (ln ).keys ())[0 ] for ln in lines ]
374+ assert keys == ["surfaceUpdate" , "beginRendering" , "dataModelUpdate" , "dataModelUpdate" ], (
375+ f"expected surfaceUpdate -> beginRendering -> dataModelUpdate × N, got { keys } "
376+ )
0 commit comments