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:
Adam Weidman
2026-02-12 16:03:20 -05:00
parent 1c926921ed
commit df81bfe1f2
5 changed files with 456 additions and 85 deletions

View File

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

View File

@@ -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[] = [
{

View File

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

View File

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

View File

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