Skip to content

fix(chat): isGenuiTurn walks back through messages (LangGraph in-place replace drops tool name)#248

Merged
blove merged 4 commits into
mainfrom
claude/fix-genui-turn-detection
May 11, 2026
Merged

fix(chat): isGenuiTurn walks back through messages (LangGraph in-place replace drops tool name)#248
blove merged 4 commits into
mainfrom
claude/fix-genui-turn-detection

Conversation

@blove
Copy link
Copy Markdown
Contributor

@blove blove commented May 11, 2026

Summary

Third pass on the GenUI streaming jank. The classifier patience fix (#246) handled the `-` ambiguity. PR #247 suppressed the markdown branch on GenUI turns. Both correct, but isGenuiTurn was still returning false on the emit-phase message — so the markdown branch fired anyway.

Root cause: `emit_generated_surface` in the Python graph performs an in-place replacement of the ToolMessage via LangGraph's `add_messages` reducer (id-match). The replacement strips the `name` field. So when isGenuiTurn's Case 3 looked at the previous message (`prevMsg.role === 'tool' && prevMsg.name in genuiTools`), `prevMsg.name` was `null` and the check failed.

Fix: walk backward through `agent.messages()` from the current index until we find either:

  • An assistant message whose `tool_calls[]` (or `content[].function_call`) references a GenUI tool → GenUI turn.
  • A human message → crossed the turn boundary; not GenUI.

This makes the detection robust to LangGraph's in-place ToolMessage replacement.

Test plan

  • `nx build chat` + `nx lint chat` green
  • Live smoke at `/embed` with a unique GenUI prompt (no cache replay):
    1. Typing indicator at bottom
    2. Skeleton appears immediately from first token
    3. No JSON visible during streaming (this is what the prior PRs missed)
    4. Surface mounts cleanly when run completes
  • CI green

Follow-up to #246 and #247.

🤖 Generated with Claude Code

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).
@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 9:06pm

Request Review

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.
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).
@blove blove merged commit ab6baeb into main May 11, 2026
14 checks passed
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