From c837f5476c7acfcd7ee1f928e6fc47e28b8e41fb Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:13:39 -0700 Subject: [PATCH 1/4] fix(chat): isGenuiTurn walks back through messages to find tool-call AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../lib/compositions/chat/chat.component.ts | 56 +++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index f7c135354..12794ef8d 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -140,7 +140,7 @@ import type { ChatRenderEvent } from './chat-render-event'; @let content = messageContent(message); @let classified = classifyMessage(content, message); @let pending = classified.type() === 'pending'; - @let genuiTurn = isGenuiTurn(message, prevMessage(i)); + @let genuiTurn = isGenuiTurn(message, prevMessage(i), i); } | null | undefined; if (!m) return false; + // Direct check on the message itself (covers the tool-call AI message). const calls = (m.extra?.['tool_calls'] as Array<{ name?: string }> | undefined) ?? []; if (calls.some(c => c.name != null && names.has(c.name))) return true; @@ -439,10 +451,32 @@ export class ChatComponent { } } - const p = prevMsg as { role?: string; name?: string; extra?: Record } | null | undefined; - if (p && p.role === 'tool') { - const toolName = (p.extra?.['name'] as string | undefined) ?? p.name; - if (typeof toolName === 'string' && names.has(toolName)) return true; + // Walk backward through messages for the emit-phase assistant + // message whose own structure has no GenUI hint. Bounded by the + // most recent human message (= start of the current turn). + if (typeof index === 'number' && index > 0) { + const msgs = this.agent().messages(); + for (let i = index - 1; i >= 0; i--) { + const prev = msgs[i] as { role?: string; extra?: Record }; + if (!prev) break; + if (prev.role === 'user') break; // crossed the turn boundary + + const prevCalls = (prev.extra?.['tool_calls'] as Array<{ name?: string }> | undefined) ?? []; + if (prevCalls.some(c => c.name != null && names.has(c.name))) return true; + + const prevRaw = prev.extra?.['content']; + if (Array.isArray(prevRaw)) { + for (const block of prevRaw) { + if (block != null + && typeof block === 'object' + && (block as { type?: unknown }).type === 'function_call' + && typeof (block as { name?: unknown }).name === 'string' + && names.has((block as { name: string }).name)) { + return true; + } + } + } + } } return false; From 75ea0d8f41765577105fa95fa471dbe21b0798b6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:24:36 -0700 Subject: [PATCH 2/4] fix(chat): isGenuiTurn also detects via content-shape markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../lib/compositions/chat/chat.component.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 12794ef8d..0598c4283 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -451,6 +451,27 @@ export class ChatComponent { } } + // Content-shape detector: during streaming, LangGraph projects the + // sub-LLM's tool_call.arguments as the assistant message's content + // string (NOT as a structured array). The structured array form + // only materialises after streaming completes. So during streaming, + // we see the JSON envelopes flowing in as text — neither tool_calls + // nor content[].function_call are populated. Detect via stable + // A2UI/json-render markers in the content string. + const projectedContent = (m as { content?: unknown }).content; + if (typeof projectedContent === 'string' && projectedContent.length > 0) { + // A2UI v1 envelope keys (canonical Google shape). + if (projectedContent.includes('"surfaceUpdate"') + || projectedContent.includes('"beginRendering"') + || projectedContent.includes('"dataModelUpdate"')) { + return true; + } + // json-render spec shape — looks like `{ "root": "...", "elements": ... }`. + if (projectedContent.includes('"root"') && projectedContent.includes('"elements"')) { + return true; + } + } + // Walk backward through messages for the emit-phase assistant // message whose own structure has no GenUI hint. Bounded by the // most recent human message (= start of the current turn). From 196450d20417d91057f5d5d2864e995cf907892e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:57:32 -0700 Subject: [PATCH 3/4] fix(chat): restore direct prev-message check in isGenuiTurn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- libs/chat/src/lib/compositions/chat/chat.component.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 0598c4283..c092f646d 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -472,6 +472,14 @@ export class ChatComponent { } } + // Direct prev-message check (fast path for the well-formed case + // where the immediately-preceding tool message still has its name). + const p = _prevMsg as { role?: string; name?: string; extra?: Record } | null | undefined; + if (p && p.role === 'tool') { + const toolName = (p.extra?.['name'] as string | undefined) ?? p.name; + if (typeof toolName === 'string' && names.has(toolName)) return true; + } + // Walk backward through messages for the emit-phase assistant // message whose own structure has no GenUI hint. Bounded by the // most recent human message (= start of the current turn). From 80b8635b25230b39bab4ab4cb286a1e98b58b4cf Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 14:03:59 -0700 Subject: [PATCH 4/4] chore: regenerate api-docs for genuiToolNames input on chat composition --- apps/website/content/docs/chat/api/api-docs.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 5b82d5c64..fa970d6ab 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1719,8 +1719,8 @@ }, { "name": "isGenuiTurn", - "signature": "isGenuiTurn(message: unknown, prevMsg: unknown)", - "description": "True when this assistant message is part of a GenUI render turn —\neither it has a tool_call to a GenUI tool, OR its content array\ncontains a function_call block for one (live during streaming),\nOR the previous message was a tool result for a GenUI tool. Used\nto gate the building-UI skeleton.", + "signature": "isGenuiTurn(message: unknown, _prevMsg: unknown, index: number)", + "description": "True when this assistant message is part of a GenUI render turn.\nWalks backward through messages from `index` until it finds either\nan assistant message with `tool_calls` referencing a GenUI tool\n(→ this turn produces a surface) or a human message (→ the\npreceding turn ended; this assistant message stands on its own).\n\nAlso checks the message itself for:\n - `extra.tool_calls[].name` matching a GenUI tool (post-streaming\n state of the tool-call AI message), OR\n - `extra.content[].type === 'function_call' && .name` matching\n (live during the OpenAI Responses-API streaming chunks before\n `tool_calls` populates).\n\nThe walk-back approach is robust to LangGraph's in-place\nreplacement of the ToolMessage (which strips the `name` field),\nunlike a single prev-message check.", "params": [ { "name": "message", @@ -1729,10 +1729,16 @@ "optional": false }, { - "name": "prevMsg", + "name": "_prevMsg", "type": "unknown", "description": "", "optional": false + }, + { + "name": "index", + "type": "number", + "description": "", + "optional": true } ] },