Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -1729,10 +1729,16 @@
"optional": false
},
{
"name": "prevMsg",
"name": "_prevMsg",
"type": "unknown",
"description": "",
"optional": false
},
{
"name": "index",
"type": "number",
"description": "",
"optional": true
}
]
},
Expand Down
79 changes: 71 additions & 8 deletions libs/chat/src/lib/compositions/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
<chat-message
[role]="'assistant'"
[message]="message"
Expand Down Expand Up @@ -412,17 +412,29 @@ export class ChatComponent {
}

/**
* True when this assistant message is part of a GenUI render turn —
* either it has a tool_call to a GenUI tool, OR its content array
* contains a function_call block for one (live during streaming),
* OR the previous message was a tool result for a GenUI tool. Used
* to gate the building-UI skeleton.
* True when this assistant message is part of a GenUI render turn.
* Walks backward through messages from `index` until it finds either
* an assistant message with `tool_calls` referencing a GenUI tool
* (→ this turn produces a surface) or a human message (→ the
* preceding turn ended; this assistant message stands on its own).
*
* Also checks the message itself for:
* - `extra.tool_calls[].name` matching a GenUI tool (post-streaming
* state of the tool-call AI message), OR
* - `extra.content[].type === 'function_call' && .name` matching
* (live during the OpenAI Responses-API streaming chunks before
* `tool_calls` populates).
*
* The walk-back approach is robust to LangGraph's in-place
* replacement of the ToolMessage (which strips the `name` field),
* unlike a single prev-message check.
*/
protected isGenuiTurn(message: unknown, prevMsg: unknown): boolean {
protected isGenuiTurn(message: unknown, _prevMsg: unknown, index?: number): boolean {
const names = new Set(this.genuiToolNames());
const m = message as { extra?: Record<string, unknown> } | 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;

Expand All @@ -439,12 +451,63 @@ export class ChatComponent {
}
}

const p = prevMsg as { role?: string; name?: string; extra?: Record<string, unknown> } | 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<string, unknown> } | 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<string, unknown> };
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;
}

Expand Down
Loading