/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * Converts A2A/A2UI responses into Google Chat messages and Cards V2. * * This renderer understands the A2UI v0.10 surface structures produced by our * a2a-server (tool approval surfaces, agent response surfaces, thought surfaces) * and converts them to Google Chat's Cards V2 format. * * Inspired by the A2UI web_core message processor pattern but simplified for * server-side rendering to a constrained card format. */ import type { ChatResponse, ChatCardV2, ChatCardSection, ChatWidget, } from './types.js'; import { type A2AResponse, extractAllParts, extractTextFromParts, extractA2UIParts, } from './a2a-bridge-client.js'; export interface ToolApprovalInfo { taskId: string; callId: string; name: string; displayName: string; description: string; args: string; kind: string; status: string; } interface AgentResponseInfo { text: string; status: string; } /** * Extracts tool approval info from an A2A response. * Used by the handler to track pending approvals for text-based confirmation. */ export function extractToolApprovals( response: A2AResponse, ): ToolApprovalInfo[] { const parts = extractAllParts(response); const a2uiMessageGroups = extractA2UIParts(parts); const toolApprovals: ToolApprovalInfo[] = []; const agentResponses: AgentResponseInfo[] = []; const thoughts: Array<{ subject: string; description: string }> = []; for (const messages of a2uiMessageGroups) { parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts); } return deduplicateToolApprovals(toolApprovals); } /** * Renders an A2A response as a Google Chat response. * Extracts text content and A2UI surfaces, converting them to Chat format. */ export function renderResponse( response: A2AResponse, threadKey?: string, threadName?: string, ): ChatResponse { const parts = extractAllParts(response); const textContent = extractTextFromParts(parts); const a2uiMessageGroups = extractA2UIParts(parts); // Parse A2UI surfaces for known types const toolApprovals: ToolApprovalInfo[] = []; const agentResponses: AgentResponseInfo[] = []; const thoughts: Array<{ subject: string; description: string }> = []; for (const messages of a2uiMessageGroups) { parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts); } // Deduplicate tool approvals by surfaceId — A2UI history contains both // initial 'awaiting_approval' and later 'success' events for auto-approved tools. const dedupedApprovals = deduplicateToolApprovals(toolApprovals); const cards: ChatCardV2[] = []; // Only render tool approval cards for tools still awaiting approval. // In YOLO mode, tools are auto-approved and their status becomes "success" // so we skip rendering approval cards for those. for (const approval of dedupedApprovals) { if (approval.status === 'awaiting_approval') { cards.push(renderToolApprovalCard(approval)); } } // Build text response from agent responses and plain text const responseTexts: string[] = []; // Add thought summaries for (const thought of thoughts) { responseTexts.push(`_${thought.subject}_: ${thought.description}`); } // Add agent response text (from A2UI surfaces). // Use only the last non-empty response since later updates supersede earlier // ones for the same surface (history contains multiple status-update messages). for (let i = agentResponses.length - 1; i >= 0; i--) { if (agentResponses[i].text) { responseTexts.push(agentResponses[i].text); break; } } // Fall back to plain text content if no A2UI response text if (responseTexts.length === 0 && textContent) { responseTexts.push(textContent); } // Add task state info if (response.kind === 'task' && response.status) { const state = response.status.state; if (state === 'input-required' && cards.length > 0) { responseTexts.push('*Waiting for your approval to continue...*'); } else if (state === 'failed') { responseTexts.push('*Task failed.*'); } else if (state === 'canceled') { responseTexts.push('*Task was cancelled.*'); } } const chatResponse: ChatResponse = {}; if (responseTexts.length > 0) { chatResponse.text = responseTexts.join('\n\n'); } if (cards.length > 0) { chatResponse.cardsV2 = cards; } if (threadKey || threadName) { chatResponse.thread = {}; if (threadKey) chatResponse.thread.threadKey = threadKey; if (threadName) chatResponse.thread.name = threadName; } // Ensure we always return something if (!chatResponse.text && !chatResponse.cardsV2) { chatResponse.text = '_Agent is processing..._'; } return chatResponse; } /** * Renders a CARD_CLICKED acknowledgment response. */ export function renderActionAcknowledgment( action: string, outcome: string, ): ChatResponse { const emoji = outcome === 'cancel' ? 'Rejected' : outcome === 'proceed_always_tool' ? 'Always Allowed' : 'Approved'; return { actionResponse: { type: 'UPDATE_MESSAGE' }, text: `*Tool ${emoji}* - Processing...`, }; } /** Safely extracts a string property from an unknown object. */ function str(obj: Record, key: string): string { const v = obj[key]; return typeof v === 'string' ? v : ''; } /** Safely checks if an unknown value is a record. */ function isRecord(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } /** Safely extracts a nested object property. */ function obj( parent: Record, key: string, ): Record | undefined { const v = parent[key]; return isRecord(v) ? v : undefined; } /** * Deduplicates tool approvals by surfaceId, keeping the last entry per surface. * In blocking mode, A2UI history accumulates ALL intermediate events — a tool * surface may appear first as 'awaiting_approval' then as 'success' (YOLO mode). * By keeping only the last entry per surfaceId, auto-approved tools show 'success'. */ function deduplicateToolApprovals( approvals: ToolApprovalInfo[], ): ToolApprovalInfo[] { const byId = new Map(); for (const a of approvals) { const key = `${a.taskId}_${a.callId}`; byId.set(key, a); } return [...byId.values()]; } /** * Parses A2UI v0.10 messages to extract known surface types. * Our server produces specific surfaces: tool approval, agent response, thought. */ function parseA2UIMessages( messages: unknown[], toolApprovals: ToolApprovalInfo[], agentResponses: AgentResponseInfo[], thoughts: Array<{ subject: string; description: string }>, ): void { for (const msg of messages) { if (!isRecord(msg)) continue; // Look for updateDataModel messages that contain tool approval or response data const updateDM = obj(msg, 'updateDataModel'); if (updateDM) { const surfaceId = str(updateDM, 'surfaceId'); const value = obj(updateDM, 'value'); const path = str(updateDM, 'path'); if (value && !path) { // Full data model update (initial) - check for known structures const tool = obj(value, 'tool'); if (surfaceId.startsWith('tool_approval_') && tool) { toolApprovals.push({ taskId: str(value, 'taskId'), callId: str(tool, 'callId'), name: str(tool, 'name'), displayName: str(tool, 'displayName'), description: str(tool, 'description'), args: str(tool, 'args'), kind: str(tool, 'kind') || 'tool', status: str(tool, 'status') || 'unknown', }); } const resp = obj(value, 'response'); if (surfaceId.startsWith('agent_response_') && resp) { agentResponses.push({ text: str(resp, 'text'), status: str(resp, 'status'), }); } } // Partial data model updates (path-based) if (path === '/response/text' && updateDM['value'] != null) { agentResponses.push({ text: String(updateDM['value']), status: '', }); } // Tool status updates (e.g., YOLO mode changes status to 'success') if ( surfaceId.startsWith('tool_approval_') && path === '/tool/status' && typeof updateDM['value'] === 'string' ) { // Find existing tool approval for this surface and update its status const existing = toolApprovals.find( (a) => `tool_approval_${a.taskId}_${a.callId}` === surfaceId, ); if (existing) { existing.status = updateDM['value']; } } } // Look for updateComponents to extract thought text const updateComp = obj(msg, 'updateComponents'); if (updateComp) { const surfaceId = str(updateComp, 'surfaceId'); const components = updateComp['components']; if (surfaceId.startsWith('thought_') && Array.isArray(components)) { const subject = extractComponentText(components, 'thought_subject'); const desc = extractComponentText(components, 'thought_desc'); if (subject || desc) { thoughts.push({ subject: subject || 'Thinking', description: desc || '', }); } } } } } /** * Extracts the text content from a named component in a component array. * Components use our a2ui-components.ts builder format. */ function extractComponentText( components: unknown[], componentId: string, ): string { for (const comp of components) { if (!isRecord(comp)) continue; if (comp['id'] === componentId && comp['component'] === 'text') { return str(comp, 'text'); } } return ''; } /** * Extracts a concise command summary from tool approval args. * For shell tools, returns just the command string. * For file tools, returns the file path. */ function extractCommandSummary(approval: ToolApprovalInfo): string { if (!approval.args || approval.args === 'No arguments') return ''; try { const parsed: unknown = JSON.parse(approval.args); if (isRecord(parsed)) { // Shell tool: {"command": "ls -F"} if (typeof parsed['command'] === 'string') { return parsed['command']; } // File tools: {"file_path": "/path/to/file", ...} if (typeof parsed['file_path'] === 'string') { const action = approval.name || approval.displayName || 'File operation'; return `${action}: ${parsed['file_path']}`; } } } catch { // Not JSON, return as-is if short enough if (approval.args.length <= 200) return approval.args; } return ''; } /** * Renders a tool approval surface as a Google Chat Card V2. */ function renderToolApprovalCard(approval: ToolApprovalInfo): ChatCardV2 { const widgets: ChatWidget[] = []; // Show a concise summary of what the tool will do. // For shell commands, extract just the command string from the args JSON. const commandSummary = extractCommandSummary(approval); if (commandSummary) { widgets.push({ decoratedText: { text: `\`${commandSummary}\``, topLabel: approval.displayName || approval.name, startIcon: { knownIcon: 'DESCRIPTION' }, wrapText: true, }, }); } else if (approval.args && approval.args !== 'No arguments') { // Fallback: show truncated args const truncatedArgs = approval.args.length > 300 ? approval.args.substring(0, 300) + '...' : approval.args; widgets.push({ decoratedText: { text: truncatedArgs, topLabel: approval.displayName || approval.name, startIcon: { knownIcon: 'DESCRIPTION' }, wrapText: true, }, }); } // Text-based approval instructions (card click buttons don't work // with the current Add-ons routing configuration) widgets.push({ textParagraph: { text: 'Reply approve, always allow, or reject', }, }); const sections: ChatCardSection[] = [ { widgets, }, ]; return { cardId: `tool_approval_${approval.callId}`, card: { header: { title: 'Tool Approval Required', subtitle: approval.displayName || approval.name, }, sections, }, }; }