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 } ] }, diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index f7c135354..c092f646d 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,12 +451,63 @@ export class ChatComponent { } } - const p = prevMsg as { role?: string; name?: string; extra?: Record } | null | undefined; + // 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; + } + } + + // 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). + 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; }