From df81bfe1f21be2830036a0ce4523134b41e7db6d Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 12 Feb 2026 16:03:20 -0500 Subject: [PATCH] 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 --- .../a2a-server/src/chat-bridge/handler.ts | 100 +++++++- .../src/chat-bridge/response-renderer.ts | 175 +++++++++----- packages/a2a-server/src/chat-bridge/routes.ts | 218 +++++++++++++++++- .../src/chat-bridge/session-store.ts | 10 + packages/a2a-server/src/http/app.ts | 38 +-- 5 files changed, 456 insertions(+), 85 deletions(-) diff --git a/packages/a2a-server/src/chat-bridge/handler.ts b/packages/a2a-server/src/chat-bridge/handler.ts index 71a93426ee..5d518705fe 100644 --- a/packages/a2a-server/src/chat-bridge/handler.ts +++ b/packages/a2a-server/src/chat-bridge/handler.ts @@ -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); diff --git a/packages/a2a-server/src/chat-bridge/response-renderer.ts b/packages/a2a-server/src/chat-bridge/response-renderer.ts index 8ceadce128..e299ccba18 100644 --- a/packages/a2a-server/src/chat-bridge/response-renderer.ts +++ b/packages/a2a-server/src/chat-bridge/response-renderer.ts @@ -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(); + 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 approve, always allow, or reject', }, - { - 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[] = [ { diff --git a/packages/a2a-server/src/chat-bridge/routes.ts b/packages/a2a-server/src/chat-bridge/routes.ts index e589a3f538..c3547bb3a1 100644 --- a/packages/a2a-server/src/chat-bridge/routes.ts +++ b/packages/a2a-server/src/chat-bridge/routes.ts @@ -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, 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 { + 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): 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 { + // Build the message object for the Add-ons format + const message: Record = {}; + 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; + + // 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'; diff --git a/packages/a2a-server/src/chat-bridge/session-store.ts b/packages/a2a-server/src/chat-bridge/session-store.ts index 9a22c7195a..ee18382aed 100644 --- a/packages/a2a-server/src/chat-bridge/session-store.ts +++ b/packages/a2a-server/src/chat-bridge/session-store.ts @@ -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; } /** diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index f2fb6be8c2..2253cb55e5 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -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);