fix(chat): GenUI turn skeleton suppresses markdown branch entirely#247
Merged
Conversation
Live smoke against PR #246's architecture revealed the classifier patience fix isn't enough on its own: during the sub-LLM phase of a GenUI run, raw JSON envelopes stream INTO the assistant message's content BEFORE emit_generated_surface wraps them with the A2UI prefix. The first chunks arrive as '[' (JSON array open) which the classifier locks in as 'markdown' — and the patience fix only protects the '-' first-char ambiguity. Fix: on a GenUI turn, suppress chat-streaming-md unconditionally until the classifier resolves to 'a2ui' or 'json-render'. Branches restructured as @else if so they're mutually exclusive, preventing any flash of streaming JSON. Once emit_generated_surface's wrapped payload arrives and the classifier's reset-on-shrink path re-classifies to 'a2ui', the skeleton hands off to the rendered surface.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
blove
added a commit
that referenced
this pull request
May 11, 2026
…e replace drops tool name) (#248) * fix(chat): isGenuiTurn walks back through messages to find tool-call AI Live smoke after #247 revealed a third source of streaming jank: the emit-phase assistant message's previous message is the tool result, which LangGraph's in-place replacement strips of its 'name' field. Case 3 of the original isGenuiTurn (prevMsg.role === 'tool' AND prevMsg.name in genuiTools) failed because prevMsg.name was null, so isGenuiTurn returned false on the emit message, and the markdown branch fired showing JSON. Fix: walk backward through agent.messages() from the current index until we find: - an assistant message with tool_calls referencing a GenUI tool (→ this turn produces a surface) - or a human message (→ crossed turn boundary; not GenUI) The walk is bounded by the most recent human message so the lookup stays O(turn length). The function signature gains an optional `index?: number`. The template now passes `i` from the @for. Existing callers without the index still work (the walk-back is skipped, falling through to the direct checks on the message itself). * fix(chat): isGenuiTurn also detects via content-shape markers Live smoke against PR #248's walk-back fix revealed yet another flow we hadn't accounted for: LangGraph projects the sub-LLM's streaming tool_call.arguments as the assistant message's content STRING (not a structured content array) during the streaming phase. The structured array only materialises after streaming completes. So during streaming both detection paths (tool_calls field, function_call content block) come up empty on what will become the tool-call AI message. Add a third detection path: scan the projected content string for stable A2UI v1 envelope markers ("surfaceUpdate", "beginRendering", "dataModelUpdate") and json-render spec markers ("root" + "elements"). Both are canonical contract identifiers per the GenUI schemas — unlikely to false-positive on regular prose. This makes isGenuiTurn robust to LangGraph's streaming oddity without requiring server-side changes to emit_generated_surface. * fix(chat): restore direct prev-message check in isGenuiTurn PR #248's refactor accidentally removed the original case-3 check (prev message is a tool with a GenUI name). The walk-back covers it for the well-formed in-app case, but the unit tests exercise isGenuiTurn(msg, prevTool) without passing an index — so they relied on the removed direct check. Restore the direct prev-message check as a fast path before the walk-back. Now all three layers fire: 1. Direct checks on the message (tool_calls, function_call content blocks, A2UI/json-render content markers). 2. Direct prev-message check (well-formed case). 3. Walk-back through messages bounded by user-message (fallback when the tool message has been stripped of its name). * chore: regenerate api-docs for genuiToolNames input on chat composition
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Live smoke against PR #246 surfaced a second source of the streaming JSON jank: during the sub-LLM phase of a GenUI turn, raw envelopes stream into the assistant message's `content` before `emit_generated_surface` wraps them with the `---a2ui_JSON---` prefix. The first chunks arrive as `[` (JSON array opening), which the classifier — even with the dash-patience fix — commits to `'markdown'`. The skeleton (correctly) doesn't fire because the classifier resolved to a non-pending type.
This PR restructures the composition's AI template so that during a GenUI turn the markdown branch is never rendered. The skeleton holds until the classifier resolves to `'a2ui'` or `'json-render'`, then hands off to the actual surface mounting.
Once `emit_generated_surface` runs server-side and replaces the streamed content with the prefixed wrapper, the classifier's content-shrink reset path re-classifies to `'a2ui'` and the surface mounts.
Test plan
Follow-up to #246.
🤖 Generated with Claude Code