Skip to content

fix(chat): GenUI turn skeleton suppresses markdown branch entirely#247

Merged
blove merged 1 commit into
mainfrom
claude/fix-genui-markdown-suppression
May 11, 2026
Merged

fix(chat): GenUI turn skeleton suppresses markdown branch entirely#247
blove merged 1 commit into
mainfrom
claude/fix-genui-markdown-suppression

Conversation

@blove
Copy link
Copy Markdown
Contributor

@blove blove commented May 11, 2026

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

  • `nx build chat` + `nx lint chat` green
  • Live smoke at `/embed` with a UNIQUE GenUI prompt (so no cache replay):
    1. Typing indicator at bottom (existing)
    2. Skeleton appears immediately, no JSON visible
    3. Skeleton stays put through the entire ~6s streaming window
    4. Rendered surface mounts cleanly when the run completes
  • Non-GenUI prompt unaffected — normal markdown streaming
  • CI green

Follow-up to #246.

🤖 Generated with Claude Code

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.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cacheplane Ready Ready Preview, Comment May 11, 2026 8:04pm

Request Review

@blove blove merged commit 6359982 into main May 11, 2026
14 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant