mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 23:21:27 -07:00
feat: Google Chat bridge with YOLO mode, text-based approvals, and Add-ons support
- Add normalizeEvent() to convert Workspace Add-ons event format to legacy ChatEvent - Add wrapAddOnsResponse() for Add-ons response wrapping - Mount chat bridge routes BEFORE A2A SDK catch-all handler - Replace card button approvals with text-based approve/reject/always allow - Add /reset, /yolo, /safe slash commands for session control - Add YOLO mode tool approval dedup (filter auto-approved surfaces) - Add extractCommandSummary() for concise tool card display - Delegate auth to Cloud Run IAM when K_SERVICE env var detected - Add JWT debug logging for token claim inspection
This commit is contained in:
@@ -16,7 +16,7 @@ import {
|
||||
A2ABridgeClient,
|
||||
extractIdsFromResponse,
|
||||
} from './a2a-bridge-client.js';
|
||||
import { renderResponse } from './response-renderer.js';
|
||||
import { renderResponse, extractToolApprovals } from './response-renderer.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export class ChatBridgeHandler {
|
||||
@@ -85,12 +85,94 @@ export class ChatBridgeHandler {
|
||||
|
||||
const threadName = message.thread.name;
|
||||
const spaceName = event.space.name;
|
||||
|
||||
// Handle slash commands
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
if (
|
||||
trimmed === '/reset' ||
|
||||
trimmed === '/clear' ||
|
||||
trimmed === 'reset' ||
|
||||
trimmed === 'clear'
|
||||
) {
|
||||
this.sessionStore.remove(threadName);
|
||||
logger.info(`[ChatBridge] Session cleared for thread ${threadName}`);
|
||||
return { text: 'Session cleared. Send a new message to start fresh.' };
|
||||
}
|
||||
|
||||
const session = this.sessionStore.getOrCreate(threadName, spaceName);
|
||||
|
||||
if (trimmed === '/yolo') {
|
||||
session.yoloMode = true;
|
||||
logger.info(`[ChatBridge] YOLO mode enabled for thread ${threadName}`);
|
||||
return {
|
||||
text: 'YOLO mode enabled. All tool calls will be auto-approved.',
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed === '/safe') {
|
||||
session.yoloMode = false;
|
||||
logger.info(`[ChatBridge] YOLO mode disabled for thread ${threadName}`);
|
||||
return { text: 'Safe mode enabled. Tool calls will require approval.' };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] MESSAGE from ${event.user.displayName}: "${text.substring(0, 100)}"`,
|
||||
);
|
||||
|
||||
// Handle text-based tool approval responses
|
||||
const lowerText = trimmed;
|
||||
if (
|
||||
session.pendingToolApproval &&
|
||||
(lowerText === 'approve' ||
|
||||
lowerText === 'yes' ||
|
||||
lowerText === 'y' ||
|
||||
lowerText === 'reject' ||
|
||||
lowerText === 'no' ||
|
||||
lowerText === 'n' ||
|
||||
lowerText === 'always allow')
|
||||
) {
|
||||
const approval = session.pendingToolApproval;
|
||||
const isReject =
|
||||
lowerText === 'reject' || lowerText === 'no' || lowerText === 'n';
|
||||
const isAlwaysAllow = lowerText === 'always allow';
|
||||
const outcome = isReject
|
||||
? 'cancel'
|
||||
: isAlwaysAllow
|
||||
? 'proceed_always_tool'
|
||||
: 'proceed_once';
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] Text-based tool ${outcome}: callId=${approval.callId}, taskId=${approval.taskId}`,
|
||||
);
|
||||
|
||||
session.pendingToolApproval = undefined;
|
||||
|
||||
try {
|
||||
const response = await this.a2aClient.sendToolConfirmation(
|
||||
approval.callId,
|
||||
outcome,
|
||||
approval.taskId,
|
||||
{ contextId: session.contextId },
|
||||
);
|
||||
|
||||
const { contextId: newCtxId, taskId: newTaskId } =
|
||||
extractIdsFromResponse(response);
|
||||
if (newCtxId) session.contextId = newCtxId;
|
||||
this.sessionStore.updateTaskId(threadName, newTaskId);
|
||||
|
||||
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 sending tool confirmation: ${errorMsg}`,
|
||||
error,
|
||||
);
|
||||
return { text: `Error processing tool confirmation: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.a2aClient.sendMessage(text, {
|
||||
contextId: session.contextId,
|
||||
@@ -104,6 +186,22 @@ export class ChatBridgeHandler {
|
||||
}
|
||||
this.sessionStore.updateTaskId(threadName, taskId);
|
||||
|
||||
// Check for pending tool approvals and store for text-based confirmation
|
||||
const approvals = extractToolApprovals(response);
|
||||
if (approvals.length > 0) {
|
||||
const firstApproval = approvals[0];
|
||||
session.pendingToolApproval = {
|
||||
callId: firstApproval.callId,
|
||||
taskId: firstApproval.taskId,
|
||||
toolName: firstApproval.displayName || firstApproval.name,
|
||||
};
|
||||
logger.info(
|
||||
`[ChatBridge] Pending tool approval: ${firstApproval.displayName || firstApproval.name} callId=${firstApproval.callId}`,
|
||||
);
|
||||
} else {
|
||||
session.pendingToolApproval = undefined;
|
||||
}
|
||||
|
||||
// Convert A2A response to Chat format
|
||||
const threadKey = message.thread.threadKey || threadName;
|
||||
return renderResponse(response, threadKey);
|
||||
|
||||
@@ -20,7 +20,6 @@ import type {
|
||||
ChatCardV2,
|
||||
ChatCardSection,
|
||||
ChatWidget,
|
||||
ChatButton,
|
||||
} from './types.js';
|
||||
import {
|
||||
type A2AResponse,
|
||||
@@ -29,7 +28,7 @@ import {
|
||||
extractA2UIParts,
|
||||
} from './a2a-bridge-client.js';
|
||||
|
||||
interface ToolApprovalInfo {
|
||||
export interface ToolApprovalInfo {
|
||||
taskId: string;
|
||||
callId: string;
|
||||
name: string;
|
||||
@@ -45,6 +44,26 @@ interface AgentResponseInfo {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool approval info from an A2A response.
|
||||
* Used by the handler to track pending approvals for text-based confirmation.
|
||||
*/
|
||||
export function extractToolApprovals(
|
||||
response: A2AResponse,
|
||||
): ToolApprovalInfo[] {
|
||||
const parts = extractAllParts(response);
|
||||
const a2uiMessageGroups = extractA2UIParts(parts);
|
||||
const toolApprovals: ToolApprovalInfo[] = [];
|
||||
const agentResponses: AgentResponseInfo[] = [];
|
||||
const thoughts: Array<{ subject: string; description: string }> = [];
|
||||
|
||||
for (const messages of a2uiMessageGroups) {
|
||||
parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts);
|
||||
}
|
||||
|
||||
return deduplicateToolApprovals(toolApprovals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an A2A response as a Google Chat response.
|
||||
* Extracts text content and A2UI surfaces, converting them to Chat format.
|
||||
@@ -66,11 +85,19 @@ export function renderResponse(
|
||||
parseA2UIMessages(messages, toolApprovals, agentResponses, thoughts);
|
||||
}
|
||||
|
||||
// Deduplicate tool approvals by surfaceId — A2UI history contains both
|
||||
// initial 'awaiting_approval' and later 'success' events for auto-approved tools.
|
||||
const dedupedApprovals = deduplicateToolApprovals(toolApprovals);
|
||||
|
||||
const cards: ChatCardV2[] = [];
|
||||
|
||||
// Render tool approval cards
|
||||
for (const approval of toolApprovals) {
|
||||
cards.push(renderToolApprovalCard(approval));
|
||||
// Only render tool approval cards for tools still awaiting approval.
|
||||
// In YOLO mode, tools are auto-approved and their status becomes "success"
|
||||
// so we skip rendering approval cards for those.
|
||||
for (const approval of dedupedApprovals) {
|
||||
if (approval.status === 'awaiting_approval') {
|
||||
cards.push(renderToolApprovalCard(approval));
|
||||
}
|
||||
}
|
||||
|
||||
// Build text response from agent responses and plain text
|
||||
@@ -99,7 +126,7 @@ export function renderResponse(
|
||||
// Add task state info
|
||||
if (response.kind === 'task' && response.status) {
|
||||
const state = response.status.state;
|
||||
if (state === 'input-required' && toolApprovals.length > 0) {
|
||||
if (state === 'input-required' && cards.length > 0) {
|
||||
responseTexts.push('*Waiting for your approval to continue...*');
|
||||
} else if (state === 'failed') {
|
||||
responseTexts.push('*Task failed.*');
|
||||
@@ -169,6 +196,23 @@ function obj(
|
||||
return isRecord(v) ? v : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates tool approvals by surfaceId, keeping the last entry per surface.
|
||||
* In blocking mode, A2UI history accumulates ALL intermediate events — a tool
|
||||
* surface may appear first as 'awaiting_approval' then as 'success' (YOLO mode).
|
||||
* By keeping only the last entry per surfaceId, auto-approved tools show 'success'.
|
||||
*/
|
||||
function deduplicateToolApprovals(
|
||||
approvals: ToolApprovalInfo[],
|
||||
): ToolApprovalInfo[] {
|
||||
const byId = new Map<string, ToolApprovalInfo>();
|
||||
for (const a of approvals) {
|
||||
const key = `${a.taskId}_${a.callId}`;
|
||||
byId.set(key, a);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses A2UI v0.10 messages to extract known surface types.
|
||||
* Our server produces specific surfaces: tool approval, agent response, thought.
|
||||
@@ -221,6 +265,21 @@ function parseA2UIMessages(
|
||||
status: '',
|
||||
});
|
||||
}
|
||||
|
||||
// Tool status updates (e.g., YOLO mode changes status to 'success')
|
||||
if (
|
||||
surfaceId.startsWith('tool_approval_') &&
|
||||
path === '/tool/status' &&
|
||||
typeof updateDM['value'] === 'string'
|
||||
) {
|
||||
// Find existing tool approval for this surface and update its status
|
||||
const existing = toolApprovals.find(
|
||||
(a) => `tool_approval_${a.taskId}_${a.callId}` === surfaceId,
|
||||
);
|
||||
if (existing) {
|
||||
existing.status = updateDM['value'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for updateComponents to extract thought text
|
||||
@@ -260,89 +319,77 @@ function extractComponentText(
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a concise command summary from tool approval args.
|
||||
* For shell tools, returns just the command string.
|
||||
* For file tools, returns the file path.
|
||||
*/
|
||||
function extractCommandSummary(approval: ToolApprovalInfo): string {
|
||||
if (!approval.args || approval.args === 'No arguments') return '';
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(approval.args);
|
||||
if (isRecord(parsed)) {
|
||||
// Shell tool: {"command": "ls -F"}
|
||||
if (typeof parsed['command'] === 'string') {
|
||||
return parsed['command'];
|
||||
}
|
||||
// File tools: {"file_path": "/path/to/file", ...}
|
||||
if (typeof parsed['file_path'] === 'string') {
|
||||
const action =
|
||||
approval.name || approval.displayName || 'File operation';
|
||||
return `${action}: ${parsed['file_path']}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, return as-is if short enough
|
||||
if (approval.args.length <= 200) return approval.args;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a tool approval surface as a Google Chat Card V2.
|
||||
*/
|
||||
function renderToolApprovalCard(approval: ToolApprovalInfo): ChatCardV2 {
|
||||
const widgets: ChatWidget[] = [];
|
||||
|
||||
// Tool description
|
||||
if (approval.description) {
|
||||
// Show a concise summary of what the tool will do.
|
||||
// For shell commands, extract just the command string from the args JSON.
|
||||
const commandSummary = extractCommandSummary(approval);
|
||||
if (commandSummary) {
|
||||
widgets.push({
|
||||
decoratedText: {
|
||||
text: approval.description,
|
||||
topLabel: 'Description',
|
||||
text: `\`${commandSummary}\``,
|
||||
topLabel: approval.displayName || approval.name,
|
||||
startIcon: { knownIcon: 'DESCRIPTION' },
|
||||
wrapText: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Arguments preview
|
||||
if (approval.args && approval.args !== 'No arguments') {
|
||||
// Truncate long args for the card
|
||||
} else if (approval.args && approval.args !== 'No arguments') {
|
||||
// Fallback: show truncated args
|
||||
const truncatedArgs =
|
||||
approval.args.length > 300
|
||||
? approval.args.substring(0, 300) + '...'
|
||||
: approval.args;
|
||||
|
||||
widgets.push({
|
||||
decoratedText: {
|
||||
text: truncatedArgs,
|
||||
topLabel: 'Arguments',
|
||||
topLabel: approval.displayName || approval.name,
|
||||
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-based approval instructions (card click buttons don't work
|
||||
// with the current Add-ons routing configuration)
|
||||
widgets.push({
|
||||
textParagraph: {
|
||||
text: 'Reply <b>approve</b>, <b>always allow</b>, or <b>reject</b>',
|
||||
},
|
||||
{
|
||||
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[] = [
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import type { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Router as createRouter } from 'express';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import type { ChatEvent, ChatBridgeConfig } from './types.js';
|
||||
import type { ChatEvent, ChatBridgeConfig, ChatResponse } from './types.js';
|
||||
import { ChatBridgeHandler } from './handler.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
@@ -67,6 +67,25 @@ function createAuthMiddleware(
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// Debug: decode token payload without verification to inspect claims
|
||||
try {
|
||||
const payloadB64 = token.split('.')[1];
|
||||
if (payloadB64) {
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(payloadB64, 'base64').toString(),
|
||||
);
|
||||
logger.info(
|
||||
`[ChatBridge] Token claims: iss=${String(decoded.iss ?? 'none')} ` +
|
||||
`aud=${String(decoded.aud ?? 'none')} ` +
|
||||
`email=${String(decoded.email ?? 'none')} ` +
|
||||
`sub=${String(decoded.sub ?? 'none')}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.warn('[ChatBridge] Could not decode token for debug logging');
|
||||
}
|
||||
|
||||
authClient
|
||||
.verifyIdToken({
|
||||
idToken: token,
|
||||
@@ -91,6 +110,170 @@ function createAuthMiddleware(
|
||||
};
|
||||
}
|
||||
|
||||
/** Safely extract a string from an unknown record. */
|
||||
function str(obj: Record<string, unknown>, key: string): string {
|
||||
const v = obj[key];
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
/** Safely check if a value is a plain object. */
|
||||
function isObj(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a Google Chat event to the legacy ChatEvent format.
|
||||
* Workspace Add-ons send: {chat: {messagePayload, user, ...}, commonEventObject}
|
||||
* Legacy format: {type: "MESSAGE", message: {...}, space: {...}, user: {...}}
|
||||
*/
|
||||
function normalizeEvent(raw: Record<string, unknown>): ChatEvent | null {
|
||||
// Already in legacy format
|
||||
if (typeof raw['type'] === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return raw as unknown as ChatEvent;
|
||||
}
|
||||
|
||||
// Workspace Add-ons format
|
||||
const chat = raw['chat'];
|
||||
if (!isObj(chat)) return null;
|
||||
|
||||
const user = isObj(chat['user']) ? chat['user'] : {};
|
||||
const eventTime = str(chat, 'eventTime');
|
||||
|
||||
// Check for card click actions (button clicks) via commonEventObject
|
||||
const common = raw['commonEventObject'];
|
||||
if (isObj(common) && typeof common['invokedFunction'] === 'string') {
|
||||
const invokedFunction = common['invokedFunction'];
|
||||
const params = isObj(common['parameters']) ? common['parameters'] : {};
|
||||
|
||||
// Build action parameters array from commonEventObject.parameters
|
||||
const actionParams = Object.entries(params)
|
||||
.filter(([, v]) => typeof v === 'string')
|
||||
.map(([key, value]) => ({ key, value: String(value) }));
|
||||
|
||||
// Extract message/thread/space from chat object
|
||||
const message = isObj(chat['message']) ? chat['message'] : {};
|
||||
const thread = isObj(message['thread']) ? message['thread'] : {};
|
||||
const space = isObj(chat['space'])
|
||||
? chat['space']
|
||||
: isObj(message['space'])
|
||||
? message['space']
|
||||
: {};
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] Add-ons CARD_CLICKED: function=${invokedFunction} ` +
|
||||
`params=${JSON.stringify(params)} thread=${str(thread, 'name')}`,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
type: 'CARD_CLICKED',
|
||||
eventTime,
|
||||
message: { ...message, thread, space },
|
||||
space,
|
||||
user,
|
||||
action: {
|
||||
actionMethodName: invokedFunction,
|
||||
parameters: actionParams,
|
||||
},
|
||||
} as unknown as ChatEvent;
|
||||
}
|
||||
|
||||
// Determine event type from which payload field is present
|
||||
if (isObj(chat['messagePayload'])) {
|
||||
const payload = chat['messagePayload'];
|
||||
const message = isObj(payload['message']) ? payload['message'] : {};
|
||||
const space = isObj(payload['space'])
|
||||
? payload['space']
|
||||
: isObj(message['space'])
|
||||
? message['space']
|
||||
: {};
|
||||
const thread = isObj(message['thread']) ? message['thread'] : {};
|
||||
|
||||
logger.info(
|
||||
`[ChatBridge] Add-ons MESSAGE: text="${str(message, 'text')}" ` +
|
||||
`space=${str(space, 'name')} thread=${str(thread, 'name')}`,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
type: 'MESSAGE',
|
||||
eventTime,
|
||||
message: {
|
||||
...message,
|
||||
sender: message['sender'] ?? user,
|
||||
thread,
|
||||
space,
|
||||
},
|
||||
space,
|
||||
user,
|
||||
} as unknown as ChatEvent;
|
||||
}
|
||||
|
||||
if (isObj(chat['addedToSpacePayload'])) {
|
||||
const payload = chat['addedToSpacePayload'];
|
||||
const space = isObj(payload['space']) ? payload['space'] : {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
type: 'ADDED_TO_SPACE',
|
||||
eventTime,
|
||||
space,
|
||||
user,
|
||||
} as unknown as ChatEvent;
|
||||
}
|
||||
|
||||
if (isObj(chat['removedFromSpacePayload'])) {
|
||||
const payload = chat['removedFromSpacePayload'];
|
||||
const space = isObj(payload['space']) ? payload['space'] : {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
type: 'REMOVED_FROM_SPACE',
|
||||
eventTime,
|
||||
space,
|
||||
user,
|
||||
} as unknown as ChatEvent;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[ChatBridge] Unknown Add-ons event, chat keys: ${Object.keys(chat).join(',')}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a legacy ChatResponse in the Workspace Add-ons response format.
|
||||
* Add-ons expects: {hostAppDataAction: {chatDataAction: {createMessageAction: {message}}}}
|
||||
*/
|
||||
function wrapAddOnsResponse(response: ChatResponse): Record<string, unknown> {
|
||||
// Build the message object for the Add-ons format
|
||||
const message: Record<string, unknown> = {};
|
||||
if (response.text) {
|
||||
message['text'] = response.text;
|
||||
}
|
||||
if (response.cardsV2) {
|
||||
message['cardsV2'] = response.cardsV2;
|
||||
}
|
||||
|
||||
// For action responses (like CARD_CLICKED acknowledgments), use updateMessageAction
|
||||
if (response.actionResponse?.type === 'UPDATE_MESSAGE') {
|
||||
return {
|
||||
hostAppDataAction: {
|
||||
chatDataAction: {
|
||||
updateMessageAction: { message },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hostAppDataAction: {
|
||||
chatDataAction: {
|
||||
createMessageAction: { message },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Express routes for the Google Chat bridge.
|
||||
*/
|
||||
@@ -106,17 +289,46 @@ export function createChatBridgeRoutes(config: ChatBridgeConfig): Router {
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const event = req.body as ChatEvent;
|
||||
const rawBody = req.body as Record<string, unknown>;
|
||||
|
||||
// Normalize to legacy ChatEvent format. Google Chat HTTP endpoints
|
||||
// configured as Workspace Add-ons send a different event structure:
|
||||
// {chat: {messagePayload, user, eventTime}, commonEventObject: {...}}
|
||||
// We convert to the legacy format our handler expects:
|
||||
// {type: "MESSAGE", message: {...}, space: {...}, user: {...}}
|
||||
const event = normalizeEvent(rawBody);
|
||||
|
||||
if (!event || !event.type) {
|
||||
logger.warn(
|
||||
`[ChatBridge] Could not parse event. Keys: ${Object.keys(rawBody).join(',')}`,
|
||||
);
|
||||
res.status(400).json({ error: 'Invalid event: missing type field' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[ChatBridge] Webhook received: type=${event.type}`);
|
||||
|
||||
// Detect if the request came in Add-ons format
|
||||
const isAddOnsFormat = Boolean(rawBody['chat'] && !rawBody['type']);
|
||||
|
||||
const response = await handler.handleEvent(event);
|
||||
res.json(response);
|
||||
|
||||
// For CARD_CLICKED events, force UPDATE_MESSAGE so the card is
|
||||
// replaced in-place rather than posting a new message.
|
||||
if (event.type === 'CARD_CLICKED' && !response.actionResponse) {
|
||||
response.actionResponse = { type: 'UPDATE_MESSAGE' };
|
||||
}
|
||||
|
||||
if (isAddOnsFormat) {
|
||||
// Wrap in Workspace Add-ons response format
|
||||
const addOnsResponse = wrapAddOnsResponse(response);
|
||||
logger.info(
|
||||
`[ChatBridge] Add-ons response: ${JSON.stringify(addOnsResponse).substring(0, 200)}`,
|
||||
);
|
||||
res.json(addOnsResponse);
|
||||
} else {
|
||||
res.json(response);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export interface PendingToolApproval {
|
||||
callId: string;
|
||||
taskId: string;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
/** A2A contextId - persists for the lifetime of the Chat thread. */
|
||||
contextId: string;
|
||||
@@ -24,6 +30,10 @@ export interface SessionInfo {
|
||||
threadName: string;
|
||||
/** Last activity timestamp. */
|
||||
lastActivity: number;
|
||||
/** Pending tool approval waiting for text-based response. */
|
||||
pendingToolApproval?: PendingToolApproval;
|
||||
/** When true, all tool calls are auto-approved. */
|
||||
yoloMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -203,6 +203,27 @@ export async function createApp() {
|
||||
requestStorage.run({ req }, next);
|
||||
});
|
||||
|
||||
// Mount Google Chat bridge routes BEFORE A2A SDK routes.
|
||||
// The A2A SDK's setupRoutes registers a catch-all jsonRpcHandler middleware
|
||||
// at baseUrl="" that intercepts ALL POST requests and returns 400 for
|
||||
// non-JSON-RPC payloads. Chat bridge must be registered first.
|
||||
const chatBridgeUrl =
|
||||
process.env['CHAT_BRIDGE_A2A_URL'] || process.env['CODER_AGENT_PORT']
|
||||
? `http://localhost:${process.env['CODER_AGENT_PORT'] || '8080'}`
|
||||
: undefined;
|
||||
if (chatBridgeUrl) {
|
||||
expressApp.use(express.json());
|
||||
const chatRoutes = createChatBridgeRoutes({
|
||||
a2aServerUrl: chatBridgeUrl,
|
||||
projectNumber: process.env['CHAT_PROJECT_NUMBER'],
|
||||
debug: process.env['CHAT_BRIDGE_DEBUG'] === 'true',
|
||||
});
|
||||
expressApp.use(chatRoutes);
|
||||
logger.info(
|
||||
`[CoreAgent] Google Chat bridge enabled at /chat/webhook (A2A: ${chatBridgeUrl})`,
|
||||
);
|
||||
}
|
||||
|
||||
const appBuilder = new A2AExpressApp(requestHandler);
|
||||
expressApp = appBuilder.setupRoutes(expressApp, '');
|
||||
expressApp.use(express.json());
|
||||
@@ -306,23 +327,6 @@ 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,
|
||||
projectNumber: process.env['CHAT_PROJECT_NUMBER'],
|
||||
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