mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 type { Command, CommandArgument } from '../commands/types.js';
|
||||||
import { GitService } from '@google/gemini-cli-core';
|
import { GitService } from '@google/gemini-cli-core';
|
||||||
import { getA2UIAgentExtension } from '../a2ui/a2ui-extension.js';
|
import { getA2UIAgentExtension } from '../a2ui/a2ui-extension.js';
|
||||||
|
import { createChatBridgeRoutes } from '../chat-bridge/routes.js';
|
||||||
|
|
||||||
type CommandResponse = {
|
type CommandResponse = {
|
||||||
name: string;
|
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) => {
|
expressApp.get('/tasks/:taskId/metadata', async (req, res) => {
|
||||||
const taskId = req.params.taskId;
|
const taskId = req.params.taskId;
|
||||||
let wrapper = agentExecutor.getTask(taskId);
|
let wrapper = agentExecutor.getTask(taskId);
|
||||||
|
|||||||
Reference in New Issue
Block a user