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
This commit is contained in:
Adam Weidman
2026-02-12 10:11:58 -05:00
parent 5ea957c84b
commit 57f3c9ca1a
7 changed files with 1150 additions and 0 deletions

View File

@@ -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<void> {
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<A2AResponse> {
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<A2AResponse> {
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<string, unknown>,
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);
}
}

View File

@@ -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<void> {
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<ChatResponse> {
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<ChatResponse> {
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<ChatResponse> {
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<ChatResponse> {
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 {};
}
}

View File

@@ -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<string, unknown>, 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<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/** Safely extracts a nested object property. */
function obj(
parent: Record<string, unknown>,
key: string,
): Record<string, unknown> | 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,
},
};
}

View File

@@ -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;
}

View File

@@ -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<string, SessionInfo>();
/**
* 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}`);
}
}
}
}

View File

@@ -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<string, unknown>;
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<string, never> };
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;
}

View File

@@ -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);