From 57f3c9ca1a4a25e2b7a2825d7e62a59fdadc020a Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 12 Feb 2026 10:11:58 -0500 Subject: [PATCH] feat: add Google Chat bridge for A2UI integration Implements a Google Chat HTTP webhook bridge that connects Google Chat to the A2A server. Each Chat thread maps to an A2A contextId/taskId pair. The bridge converts A2UI tool approval surfaces to Google Chat Cards V2 with Approve/Always Allow/Reject buttons, and handles CARD_CLICKED events to forward tool confirmations back to the A2A server. Components: - chat-bridge/types.ts: Google Chat event/response types - chat-bridge/session-store.ts: Thread -> A2A session mapping - chat-bridge/a2a-bridge-client.ts: A2A SDK client wrapper - chat-bridge/response-renderer.ts: A2UI -> Google Chat Cards V2 - chat-bridge/handler.ts: Event handler (MESSAGE, CARD_CLICKED) - chat-bridge/routes.ts: Express routes mounted at /chat/webhook --- .../src/chat-bridge/a2a-bridge-client.ts | 250 ++++++++++++ .../a2a-server/src/chat-bridge/handler.ts | 230 +++++++++++ .../src/chat-bridge/response-renderer.ts | 360 ++++++++++++++++++ packages/a2a-server/src/chat-bridge/routes.ts | 59 +++ .../src/chat-bridge/session-store.ts | 95 +++++ packages/a2a-server/src/chat-bridge/types.ts | 139 +++++++ packages/a2a-server/src/http/app.ts | 17 + 7 files changed, 1150 insertions(+) create mode 100644 packages/a2a-server/src/chat-bridge/a2a-bridge-client.ts create mode 100644 packages/a2a-server/src/chat-bridge/handler.ts create mode 100644 packages/a2a-server/src/chat-bridge/response-renderer.ts create mode 100644 packages/a2a-server/src/chat-bridge/routes.ts create mode 100644 packages/a2a-server/src/chat-bridge/session-store.ts create mode 100644 packages/a2a-server/src/chat-bridge/types.ts diff --git a/packages/a2a-server/src/chat-bridge/a2a-bridge-client.ts b/packages/a2a-server/src/chat-bridge/a2a-bridge-client.ts new file mode 100644 index 0000000000..649d83e873 --- /dev/null +++ b/packages/a2a-server/src/chat-bridge/a2a-bridge-client.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A2A client wrapper for the Google Chat bridge. + * Connects to the A2A server (local or remote) and sends/receives messages. + * Follows the patterns from core/agents/a2a-client-manager.ts and + * core/agents/remote-invocation.ts. + */ + +import type { Message, Task, Part, MessageSendParams } from '@a2a-js/sdk'; +import { + type Client, + ClientFactory, + ClientFactoryOptions, + DefaultAgentCardResolver, + RestTransportFactory, + JsonRpcTransportFactory, +} from '@a2a-js/sdk/client'; +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../utils/logger.js'; +import { A2UI_EXTENSION_URI, A2UI_MIME_TYPE } from '../a2ui/a2ui-extension.js'; + +export type A2AResponse = Message | Task; + +/** + * Extracts contextId and taskId from an A2A response. + * Follows extractIdsFromResponse pattern from a2aUtils.ts. + */ +export function extractIdsFromResponse(result: A2AResponse): { + contextId?: string; + taskId?: string; +} { + if (result.kind === 'message') { + return { + contextId: result.contextId, + taskId: result.taskId, + }; + } + + if (result.kind === 'task') { + const contextId = result.contextId; + let taskId: string | undefined = result.id; + + // Clear taskId on terminal states so next interaction starts a fresh task + const state = result.status?.state; + if (state === 'completed' || state === 'failed' || state === 'canceled') { + taskId = undefined; + } + + return { contextId, taskId }; + } + + return {}; +} + +/** + * Extracts all parts from an A2A response (from status message + artifacts). + */ +export function extractAllParts(result: A2AResponse): Part[] { + const parts: Part[] = []; + + if (result.kind === 'message') { + parts.push(...(result.parts ?? [])); + } else if (result.kind === 'task') { + // Parts from the status message + if (result.status?.message?.parts) { + parts.push(...result.status.message.parts); + } + // Parts from artifacts + if (result.artifacts) { + for (const artifact of result.artifacts) { + parts.push(...(artifact.parts ?? [])); + } + } + } + + return parts; +} + +/** + * Extracts plain text content from response parts. + */ +export function extractTextFromParts(parts: Part[]): string { + return parts + .filter((p) => p.kind === 'text') + .map((p) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (p as unknown as { text: string }).text + ) + .filter(Boolean) + .join('\n'); +} + +/** + * Extracts A2UI data parts from response parts. + * A2UI parts are DataParts with metadata.mimeType === 'application/json+a2ui'. + */ +export function extractA2UIParts(parts: Part[]): unknown[][] { + const a2uiMessages: unknown[][] = []; + + for (const part of parts) { + if ( + part.kind === 'data' && + part.metadata != null && + part.metadata['mimeType'] === A2UI_MIME_TYPE + ) { + // The data field is an array of A2UI messages + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const data = (part as unknown as { data: unknown }).data; + if (Array.isArray(data)) { + a2uiMessages.push(data); + } + } + } + + return a2uiMessages; +} + +/** + * A2A client for the chat bridge. + * Manages connection to the A2A server and provides message send/receive. + */ +export class A2ABridgeClient { + private client: Client | null = null; + private agentUrl: string; + + constructor(agentUrl: string) { + this.agentUrl = agentUrl; + } + + /** + * Initializes the client connection to the A2A server. + */ + async initialize(): Promise { + if (this.client) return; + + const resolver = new DefaultAgentCardResolver({}); + const options = ClientFactoryOptions.createFrom( + ClientFactoryOptions.default, + { + transports: [ + new RestTransportFactory({}), + new JsonRpcTransportFactory({}), + ], + cardResolver: resolver, + }, + ); + + const factory = new ClientFactory(options); + this.client = await factory.createFromUrl(this.agentUrl, ''); + + const card = await this.client.getAgentCard(); + logger.info( + `[ChatBridge] Connected to A2A agent: ${card.name} (${card.url})`, + ); + } + + /** + * Sends a text message to the A2A server. + * Includes A2UI extension metadata so the server enables A2UI mode. + */ + async sendMessage( + text: string, + options: { contextId?: string; taskId?: string }, + ): Promise { + if (!this.client) { + throw new Error('A2A client not initialized. Call initialize() first.'); + } + + const params: MessageSendParams = { + message: { + kind: 'message', + role: 'user', + messageId: uuidv4(), + parts: [{ kind: 'text', text }], + contextId: options.contextId, + taskId: options.taskId, + // Signal A2UI support in message metadata + metadata: { + extensions: [A2UI_EXTENSION_URI], + }, + }, + configuration: { + blocking: true, + }, + }; + + return this.client.sendMessage(params); + } + + /** + * Sends a tool confirmation action back to the A2A server. + * The action is sent as a DataPart containing the A2UI action message. + */ + async sendToolConfirmation( + callId: string, + outcome: string, + taskId: string, + options: { contextId?: string }, + ): Promise { + if (!this.client) { + throw new Error('A2A client not initialized. Call initialize() first.'); + } + + // Build the A2UI action message as a DataPart + const actionPart: Part = { + kind: 'data', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + data: [ + { + version: 'v0.10', + action: { + name: 'tool_confirmation', + surfaceId: `tool_approval_${taskId}_${callId}`, + sourceComponentId: + outcome === 'cancel' ? 'reject_button' : 'approve_button', + timestamp: new Date().toISOString(), + context: { callId, outcome, taskId }, + }, + }, + ] as unknown as Record, + metadata: { + mimeType: A2UI_MIME_TYPE, + }, + } as Part; + + const params: MessageSendParams = { + message: { + kind: 'message', + role: 'user', + messageId: uuidv4(), + parts: [actionPart], + contextId: options.contextId, + taskId, + metadata: { + extensions: [A2UI_EXTENSION_URI], + }, + }, + configuration: { + blocking: true, + }, + }; + + return this.client.sendMessage(params); + } +} diff --git a/packages/a2a-server/src/chat-bridge/handler.ts b/packages/a2a-server/src/chat-bridge/handler.ts new file mode 100644 index 0000000000..71a93426ee --- /dev/null +++ b/packages/a2a-server/src/chat-bridge/handler.ts @@ -0,0 +1,230 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Google Chat webhook handler. + * Processes incoming Google Chat events, forwards them to the A2A server, + * and converts responses back to Google Chat format. + */ + +import type { ChatEvent, ChatResponse, ChatBridgeConfig } from './types.js'; +import { SessionStore } from './session-store.js'; +import { + A2ABridgeClient, + extractIdsFromResponse, +} from './a2a-bridge-client.js'; +import { renderResponse } from './response-renderer.js'; +import { logger } from '../utils/logger.js'; + +export class ChatBridgeHandler { + private sessionStore: SessionStore; + private a2aClient: A2ABridgeClient; + private initialized = false; + + constructor(private config: ChatBridgeConfig) { + this.sessionStore = new SessionStore(); + this.a2aClient = new A2ABridgeClient(config.a2aServerUrl); + } + + /** + * Initializes the A2A client connection. + * Must be called before handling events. + */ + async initialize(): Promise { + if (this.initialized) return; + await this.a2aClient.initialize(); + this.initialized = true; + logger.info( + `[ChatBridge] Handler initialized, connected to ${this.config.a2aServerUrl}`, + ); + } + + /** + * Main entry point for handling Google Chat webhook events. + */ + async handleEvent(event: ChatEvent): Promise { + if (!this.initialized) { + await this.initialize(); + } + + logger.info( + `[ChatBridge] Received event: type=${event.type}, space=${event.space.name}`, + ); + + switch (event.type) { + case 'MESSAGE': + return this.handleMessage(event); + case 'CARD_CLICKED': + return this.handleCardClicked(event); + case 'ADDED_TO_SPACE': + return this.handleAddedToSpace(event); + case 'REMOVED_FROM_SPACE': + return this.handleRemovedFromSpace(event); + default: + logger.warn(`[ChatBridge] Unknown event type: ${event.type}`); + return { text: 'Unknown event type.' }; + } + } + + /** + * Handles a MESSAGE event: user sent a text message in Chat. + */ + private async handleMessage(event: ChatEvent): Promise { + const message = event.message; + if (!message?.thread?.name) { + return { text: 'Error: Missing thread information.' }; + } + + const text = message.argumentText || message.text || ''; + if (!text.trim()) { + return { text: "I didn't receive any text. Please try again." }; + } + + const threadName = message.thread.name; + const spaceName = event.space.name; + const session = this.sessionStore.getOrCreate(threadName, spaceName); + + logger.info( + `[ChatBridge] MESSAGE from ${event.user.displayName}: "${text.substring(0, 100)}"`, + ); + + try { + const response = await this.a2aClient.sendMessage(text, { + contextId: session.contextId, + taskId: session.taskId, + }); + + // Update session with new IDs from response + const { contextId, taskId } = extractIdsFromResponse(response); + if (contextId) { + session.contextId = contextId; + } + this.sessionStore.updateTaskId(threadName, taskId); + + // Convert A2A response to Chat format + const threadKey = message.thread.threadKey || threadName; + return renderResponse(response, threadKey); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`[ChatBridge] Error handling message: ${errorMsg}`, error); + return { + text: `Sorry, I encountered an error processing your request: ${errorMsg}`, + }; + } + } + + /** + * Handles a CARD_CLICKED event: user clicked a button on a card. + * Used for tool approval/rejection flows. + */ + private async handleCardClicked(event: ChatEvent): Promise { + const action = event.action; + if (!action) { + return { text: 'Error: Missing action data.' }; + } + + const threadName = event.message?.thread?.name; + if (!threadName) { + return { text: 'Error: Missing thread information.' }; + } + + const session = this.sessionStore.get(threadName); + if (!session) { + return { text: 'Error: No active session found for this thread.' }; + } + + logger.info( + `[ChatBridge] CARD_CLICKED: function=${action.actionMethodName}`, + ); + + if (action.actionMethodName === 'tool_confirmation') { + return this.handleToolConfirmation(event, session.contextId); + } + + return { text: `Unknown action: ${action.actionMethodName}` }; + } + + /** + * Handles tool confirmation actions from card button clicks. + */ + private async handleToolConfirmation( + event: ChatEvent, + contextId: string, + ): Promise { + const params = event.action?.parameters || []; + const paramMap = new Map(params.map((p) => [p.key, p.value])); + + const callId = paramMap.get('callId'); + const outcome = paramMap.get('outcome'); + const taskId = paramMap.get('taskId'); + + if (!callId || !outcome || !taskId) { + return { text: 'Error: Missing tool confirmation parameters.' }; + } + + logger.info( + `[ChatBridge] Tool confirmation: callId=${callId}, outcome=${outcome}, taskId=${taskId}`, + ); + + try { + const response = await this.a2aClient.sendToolConfirmation( + callId, + outcome, + taskId, + { contextId }, + ); + + // Update session + const threadName = event.message?.thread?.name; + if (threadName) { + const { contextId: newContextId, taskId: newTaskId } = + extractIdsFromResponse(response); + if (newContextId) { + const session = this.sessionStore.get(threadName); + if (session) session.contextId = newContextId; + } + this.sessionStore.updateTaskId(threadName, newTaskId); + } + + return renderResponse(response); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error( + `[ChatBridge] Error sending tool confirmation: ${errorMsg}`, + error, + ); + return { + text: `Error processing tool confirmation: ${errorMsg}`, + }; + } + } + + /** + * Handles ADDED_TO_SPACE event: bot was added to a space or DM. + */ + private handleAddedToSpace(event: ChatEvent): ChatResponse { + const spaceType = event.space.type === 'DM' ? 'DM' : 'space'; + logger.info(`[ChatBridge] Bot added to ${spaceType}: ${event.space.name}`); + return { + text: + `Hello! I'm the Gemini CLI Agent. Send me a message to get started with code generation and development tasks.\n\n` + + `I can:\n` + + `- Generate code from natural language\n` + + `- Edit files and run commands\n` + + `- Answer questions about code\n\n` + + `I'll ask for your approval before executing tools.`, + }; + } + + /** + * Handles REMOVED_FROM_SPACE event: bot was removed from a space. + */ + private handleRemovedFromSpace(event: ChatEvent): ChatResponse { + logger.info(`[ChatBridge] Bot removed from space: ${event.space.name}`); + // Clean up any sessions for this space + return {}; + } +} diff --git a/packages/a2a-server/src/chat-bridge/response-renderer.ts b/packages/a2a-server/src/chat-bridge/response-renderer.ts new file mode 100644 index 0000000000..123aca553a --- /dev/null +++ b/packages/a2a-server/src/chat-bridge/response-renderer.ts @@ -0,0 +1,360 @@ +/** + * @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, + ChatButton, +} from './types.js'; +import { + type A2AResponse, + extractAllParts, + extractTextFromParts, + extractA2UIParts, +} from './a2a-bridge-client.js'; + +interface ToolApprovalInfo { + taskId: string; + callId: string; + name: string; + displayName: string; + description: string; + args: string; + kind: string; + status: string; +} + +interface AgentResponseInfo { + text: string; + status: string; +} + +/** + * 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, +): 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); + } + + const cards: ChatCardV2[] = []; + + // Render tool approval cards + for (const approval of toolApprovals) { + 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) + for (const agentResponse of agentResponses) { + if (agentResponse.text) { + responseTexts.push(agentResponse.text); + } + } + + // 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' && toolApprovals.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) { + chatResponse.thread = { threadKey }; + } + + // 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; +} + +/** + * 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: '', + }); + } + } + + // 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 ''; +} + +/** + * Renders a tool approval surface as a Google Chat Card V2. + */ +function renderToolApprovalCard(approval: ToolApprovalInfo): ChatCardV2 { + const widgets: ChatWidget[] = []; + + // Tool description + if (approval.description) { + widgets.push({ + decoratedText: { + text: approval.description, + topLabel: 'Description', + wrapText: true, + }, + }); + } + + // Arguments preview + if (approval.args && approval.args !== 'No arguments') { + // Truncate long args for the card + const truncatedArgs = + approval.args.length > 300 + ? approval.args.substring(0, 300) + '...' + : approval.args; + + widgets.push({ + decoratedText: { + text: truncatedArgs, + topLabel: 'Arguments', + startIcon: { knownIcon: 'DESCRIPTION' }, + wrapText: true, + }, + }); + } + + widgets.push({ divider: {} }); + + // Action buttons + const buttons: ChatButton[] = [ + { + text: 'Approve', + onClick: { + action: { + function: 'tool_confirmation', + parameters: [ + { key: 'callId', value: approval.callId }, + { key: 'outcome', value: 'proceed_once' }, + { key: 'taskId', value: approval.taskId }, + ], + }, + }, + color: { red: 0.1, green: 0.45, blue: 0.91 }, + }, + { + text: 'Always Allow', + onClick: { + action: { + function: 'tool_confirmation', + parameters: [ + { key: 'callId', value: approval.callId }, + { key: 'outcome', value: 'proceed_always_tool' }, + { key: 'taskId', value: approval.taskId }, + ], + }, + }, + }, + { + text: 'Reject', + onClick: { + action: { + function: 'tool_confirmation', + parameters: [ + { key: 'callId', value: approval.callId }, + { key: 'outcome', value: 'cancel' }, + { key: 'taskId', value: approval.taskId }, + ], + }, + }, + color: { red: 0.85, green: 0.2, blue: 0.2 }, + }, + ]; + + widgets.push({ buttonList: { buttons } }); + + const sections: ChatCardSection[] = [ + { + widgets, + }, + ]; + + return { + cardId: `tool_approval_${approval.callId}`, + card: { + header: { + title: 'Tool Approval Required', + subtitle: approval.displayName || approval.name, + }, + sections, + }, + }; +} diff --git a/packages/a2a-server/src/chat-bridge/routes.ts b/packages/a2a-server/src/chat-bridge/routes.ts new file mode 100644 index 0000000000..444c2bc484 --- /dev/null +++ b/packages/a2a-server/src/chat-bridge/routes.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Express routes for the Google Chat bridge webhook. + * Adds a POST /chat/webhook endpoint to the existing Express app. + */ + +import type { Router, Request, Response } from 'express'; +import { Router as createRouter } from 'express'; +import type { ChatEvent, ChatBridgeConfig } from './types.js'; +import { ChatBridgeHandler } from './handler.js'; +import { logger } from '../utils/logger.js'; + +/** + * Creates Express routes for the Google Chat bridge. + */ +export function createChatBridgeRoutes(config: ChatBridgeConfig): Router { + const router = createRouter(); + const handler = new ChatBridgeHandler(config); + + // Google Chat sends webhook events as POST requests + router.post('/chat/webhook', async (req: Request, res: Response) => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const event = req.body as ChatEvent; + + if (!event || !event.type) { + res.status(400).json({ error: 'Invalid event: missing type field' }); + return; + } + + logger.info(`[ChatBridge] Webhook received: type=${event.type}`); + + const response = await handler.handleEvent(event); + res.json(response); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`[ChatBridge] Webhook error: ${errorMsg}`, error); + res.status(500).json({ + text: `Internal error: ${errorMsg}`, + }); + } + }); + + // Health check endpoint for the chat bridge + router.get('/chat/health', (_req: Request, res: Response) => { + res.json({ + status: 'ok', + bridge: 'google-chat', + a2aServerUrl: config.a2aServerUrl, + }); + }); + + return router; +} diff --git a/packages/a2a-server/src/chat-bridge/session-store.ts b/packages/a2a-server/src/chat-bridge/session-store.ts new file mode 100644 index 0000000000..9a22c7195a --- /dev/null +++ b/packages/a2a-server/src/chat-bridge/session-store.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Manages mapping between Google Chat threads and A2A sessions. + * Each Google Chat thread maintains a persistent contextId (conversation) + * and a transient taskId (active task within that conversation). + */ + +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../utils/logger.js'; + +export interface SessionInfo { + /** A2A contextId - persists for the lifetime of the Chat thread. */ + contextId: string; + /** A2A taskId - cleared on terminal states, reused on input-required. */ + taskId?: string; + /** Space name for async messaging. */ + spaceName: string; + /** Thread name for async messaging. */ + threadName: string; + /** Last activity timestamp. */ + lastActivity: number; +} + +/** + * In-memory session store mapping Google Chat thread names to A2A sessions. + */ +export class SessionStore { + private sessions = new Map(); + + /** + * Gets or creates a session for a Google Chat thread. + */ + getOrCreate(threadName: string, spaceName: string): SessionInfo { + let session = this.sessions.get(threadName); + if (!session) { + session = { + contextId: uuidv4(), + spaceName, + threadName, + lastActivity: Date.now(), + }; + this.sessions.set(threadName, session); + logger.info( + `[ChatBridge] New session for thread ${threadName}: contextId=${session.contextId}`, + ); + } + session.lastActivity = Date.now(); + return session; + } + + /** + * Gets an existing session by thread name. + */ + get(threadName: string): SessionInfo | undefined { + return this.sessions.get(threadName); + } + + /** + * Updates the taskId for a session. + */ + updateTaskId(threadName: string, taskId: string | undefined): void { + const session = this.sessions.get(threadName); + if (session) { + session.taskId = taskId; + logger.info( + `[ChatBridge] Session ${threadName}: taskId=${taskId ?? 'cleared'}`, + ); + } + } + + /** + * Removes a session (e.g. when bot is removed from space). + */ + remove(threadName: string): void { + this.sessions.delete(threadName); + } + + /** + * Cleans up stale sessions older than the given max age (ms). + */ + cleanup(maxAgeMs: number = 24 * 60 * 60 * 1000): void { + const now = Date.now(); + for (const [threadName, session] of this.sessions.entries()) { + if (now - session.lastActivity > maxAgeMs) { + this.sessions.delete(threadName); + logger.info(`[ChatBridge] Cleaned up stale session: ${threadName}`); + } + } + } +} diff --git a/packages/a2a-server/src/chat-bridge/types.ts b/packages/a2a-server/src/chat-bridge/types.ts new file mode 100644 index 0000000000..7ef3daa24f --- /dev/null +++ b/packages/a2a-server/src/chat-bridge/types.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Google Chat HTTP endpoint event types. + * @see https://developers.google.com/workspace/chat/api/reference/rest/v1/Event + */ + +export interface ChatUser { + name: string; + displayName: string; + type?: 'HUMAN' | 'BOT'; +} + +export interface ChatThread { + name: string; + threadKey?: string; +} + +export interface ChatSpace { + name: string; + type: 'DM' | 'ROOM' | 'SPACE'; + displayName?: string; +} + +export interface ChatMessage { + name: string; + sender: ChatUser; + createTime: string; + text?: string; + argumentText?: string; + thread: ChatThread; + space: ChatSpace; + cardsV2?: ChatCardV2[]; +} + +export interface ChatActionParameter { + key: string; + value: string; +} + +export interface ChatAction { + actionMethodName: string; + parameters: ChatActionParameter[]; +} + +export type ChatEventType = + | 'MESSAGE' + | 'CARD_CLICKED' + | 'ADDED_TO_SPACE' + | 'REMOVED_FROM_SPACE'; + +export interface ChatEvent { + type: ChatEventType; + eventTime: string; + message?: ChatMessage; + space: ChatSpace; + user: ChatUser; + action?: ChatAction; + common?: Record; + threadKey?: string; +} + +// Google Chat Cards V2 response types + +export interface ChatCardV2 { + cardId: string; + card: ChatCard; +} + +export interface ChatCard { + header?: ChatCardHeader; + sections: ChatCardSection[]; +} + +export interface ChatCardHeader { + title: string; + subtitle?: string; + imageUrl?: string; + imageType?: 'CIRCLE' | 'SQUARE'; +} + +export interface ChatCardSection { + header?: string; + widgets: ChatWidget[]; + collapsible?: boolean; + uncollapsibleWidgetsCount?: number; +} + +export type ChatWidget = + | { textParagraph: { text: string } } + | { decoratedText: ChatDecoratedText } + | { buttonList: { buttons: ChatButton[] } } + | { divider: Record }; + +export interface ChatDecoratedText { + text: string; + topLabel?: string; + bottomLabel?: string; + startIcon?: { knownIcon: string }; + wrapText?: boolean; +} + +export interface ChatButton { + text: string; + onClick: ChatOnClick; + color?: { red: number; green: number; blue: number; alpha?: number }; + disabled?: boolean; +} + +export interface ChatOnClick { + action: { + function: string; + parameters: ChatActionParameter[]; + }; +} + +export interface ChatResponse { + text?: string; + cardsV2?: ChatCardV2[]; + thread?: { threadKey: string }; + actionResponse?: { + type: 'NEW_MESSAGE' | 'UPDATE_MESSAGE' | 'REQUEST_CONFIG'; + }; +} + +// Bridge configuration + +export interface ChatBridgeConfig { + /** URL of the A2A server to connect to (e.g. http://localhost:8080) */ + a2aServerUrl: string; + /** Google Chat project number for verification (optional) */ + projectNumber?: string; + /** Whether to enable debug logging */ + debug?: boolean; +} diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 064a65d418..328e91c10e 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -29,6 +29,7 @@ import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core'; import type { Command, CommandArgument } from '../commands/types.js'; import { GitService } from '@google/gemini-cli-core'; import { getA2UIAgentExtension } from '../a2ui/a2ui-extension.js'; +import { createChatBridgeRoutes } from '../chat-bridge/routes.js'; type CommandResponse = { name: string; @@ -305,6 +306,22 @@ export async function createApp() { } }); + // Mount Google Chat bridge routes if configured + const chatBridgeUrl = + process.env['CHAT_BRIDGE_A2A_URL'] || process.env['CODER_AGENT_PORT'] + ? `http://localhost:${process.env['CODER_AGENT_PORT'] || '8080'}` + : undefined; + if (chatBridgeUrl) { + const chatRoutes = createChatBridgeRoutes({ + a2aServerUrl: chatBridgeUrl, + debug: process.env['CHAT_BRIDGE_DEBUG'] === 'true', + }); + expressApp.use(chatRoutes); + logger.info( + `[CoreAgent] Google Chat bridge enabled at /chat/webhook (A2A: ${chatBridgeUrl})`, + ); + } + expressApp.get('/tasks/:taskId/metadata', async (req, res) => { const taskId = req.params.taskId; let wrapper = agentExecutor.getTask(taskId);