mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 08:31:14 -07:00
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:
250
packages/a2a-server/src/chat-bridge/a2a-bridge-client.ts
Normal file
250
packages/a2a-server/src/chat-bridge/a2a-bridge-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
230
packages/a2a-server/src/chat-bridge/handler.ts
Normal file
230
packages/a2a-server/src/chat-bridge/handler.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
360
packages/a2a-server/src/chat-bridge/response-renderer.ts
Normal file
360
packages/a2a-server/src/chat-bridge/response-renderer.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
59
packages/a2a-server/src/chat-bridge/routes.ts
Normal file
59
packages/a2a-server/src/chat-bridge/routes.ts
Normal 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;
|
||||
}
|
||||
95
packages/a2a-server/src/chat-bridge/session-store.ts
Normal file
95
packages/a2a-server/src/chat-bridge/session-store.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
packages/a2a-server/src/chat-bridge/types.ts
Normal file
139
packages/a2a-server/src/chat-bridge/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user