Skip to content

Commit ca0211b

Browse files
bloveclaude
andauthored
test(examples-chat): regression coverage for GenUI emit coalescing (PR C) (#299)
* docs(plans): progressive GenUI rendering + bubble coalescing (chat-side wiring) Three independent PRs per spec's phasing (order A → B → C): - PR A — Surface store + catalog shape (lib only) [this branch] - PR B — <a2ui-surface> per-component rendering (lib only) - PR C — Backend coalescing + envelope reordering (Python only) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(examples-chat): GenUI emit coalescing + reorder regressions Asserts emit_generated_surface returns 2 replacements (3-message thread post-merge, not 4), preserves the upstream tool-call AI's id, tool_calls, additional_kwargs, response_metadata, and orders the wrapped envelopes surfaceUpdate -> beginRendering -> dataModelUpdate. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b0633f commit ca0211b

1 file changed

Lines changed: 102 additions & 0 deletions

File tree

examples/chat/python/tests/test_graph_smoke.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)