diff --git a/assistant/src/__tests__/channel-approval-routes.test.ts b/assistant/src/__tests__/channel-approval-routes.test.ts index 6ad9162f6f0..4c5d33b593f 100644 --- a/assistant/src/__tests__/channel-approval-routes.test.ts +++ b/assistant/src/__tests__/channel-approval-routes.test.ts @@ -1524,8 +1524,8 @@ describe('SMS channel approval decisions', () => { test('non-decision SMS message during pending approval triggers reminder with plain-text fallback', async () => { const orchestrator = makeMockOrchestrator(); - const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined); - const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined); + const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined); + const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined); const initReq = makeSmsInboundRequest({ content: 'init' }); await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator); @@ -1545,15 +1545,21 @@ describe('SMS channel approval decisions', () => { expect(body.accepted).toBe(true); expect(body.approval).toBe('reminder_sent'); - // SMS is a non-rich channel so the delivered text should include plain-text fallback + // SMS is non-rich: reminder is delivered as plain text without approval metadata. expect(deliverSpy).toHaveBeenCalled(); - const callArgs = deliverSpy.mock.calls[0]; - const deliveredText = callArgs[2] as string; + expect(approvalSpy).not.toHaveBeenCalled(); + const reminderCall = deliverSpy.mock.calls.find( + (call) => typeof call[1] === 'object' && (call[1] as { chatId?: string }).chatId === 'sms-chat-123', + ); + expect(reminderCall).toBeDefined(); + const reminderPayload = reminderCall![1] as { text?: string; approval?: unknown }; + const deliveredText = reminderPayload.text ?? ''; expect(deliveredText).toContain("I'm still waiting"); expect(deliveredText).toContain('Reply "yes"'); + expect(reminderPayload.approval).toBeUndefined(); deliverSpy.mockRestore(); - replySpy.mockRestore(); + approvalSpy.mockRestore(); }); test('sourceChannel "sms" is passed to orchestrator.startRun', async () => { @@ -1773,8 +1779,8 @@ describe('plain-text fallback surfacing for non-rich channels', () => { test('reminder prompt includes plainTextFallback for non-rich channel (http-api)', async () => { const orchestrator = makeMockOrchestrator(); - const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined); - const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined); + const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined); + const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined); // Establish the conversation using http-api (non-rich channel) const initReq = makeInboundRequest({ content: 'init', sourceChannel: 'http-api' }); @@ -1796,17 +1802,23 @@ describe('plain-text fallback surfacing for non-rich channels', () => { expect(body.accepted).toBe(true); expect(body.approval).toBe('reminder_sent'); - // The delivered text should include the plainTextFallback instructions + // Non-rich channel uses plain-text delivery with fallback instructions. expect(deliverSpy).toHaveBeenCalled(); - const callArgs = deliverSpy.mock.calls[0]; - const deliveredText = callArgs[2] as string; + expect(approvalSpy).not.toHaveBeenCalled(); + const reminderCall = deliverSpy.mock.calls.find( + (call) => typeof call[1] === 'object' && (call[1] as { chatId?: string }).chatId === 'chat-123', + ); + expect(reminderCall).toBeDefined(); + const reminderPayload = reminderCall![1] as { text?: string; approval?: unknown }; + const deliveredText = reminderPayload.text ?? ''; // For non-rich channels, the text should contain both the reminder prefix // AND the plainTextFallback instructions (e.g. "Reply yes to approve") expect(deliveredText).toContain("I'm still waiting"); expect(deliveredText).toContain('Reply "yes"'); + expect(reminderPayload.approval).toBeUndefined(); deliverSpy.mockRestore(); - replySpy.mockRestore(); + approvalSpy.mockRestore(); }); test('reminder prompt does NOT include plainTextFallback for telegram (rich channel)', async () => { @@ -2188,14 +2200,14 @@ describe('guardian-with-binding path regression', () => { }); // ═══════════════════════════════════════════════════════════════════════════ -// 20. Guardian delivery failure denial (WS-2) +// 20. Guardian rich-delivery failure fallback (WS-2) // ═══════════════════════════════════════════════════════════════════════════ -describe('guardian delivery failure → denial', () => { +describe('guardian delivery failure → text fallback', () => { beforeEach(() => { }); - test('delivery failure denies run and notifies requester', async () => { + test('rich delivery failure falls back to plain text and keeps request pending', async () => { createBinding({ assistantId: 'self', channel: 'telegram', @@ -2220,29 +2232,30 @@ describe('guardian delivery failure → denial', () => { await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator); await new Promise((resolve) => setTimeout(resolve, 1200)); - // The run should have been denied - expect(orchestrator.submitDecision).toHaveBeenCalled(); - const decisionArgs = (orchestrator.submitDecision as ReturnType).mock.calls[0]; - expect(decisionArgs[1]).toBe('deny'); + // Rich button delivery failed, but plain-text fallback succeeded. + expect(orchestrator.submitDecision).not.toHaveBeenCalled(); + expect(approvalSpy).toHaveBeenCalled(); - // Requester should have been notified that delivery failed - const failureCalls = deliverSpy.mock.calls.filter( - (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('denied'), + // Guardian should have received a parser-compatible plain-text approval prompt. + const guardianPromptCalls = deliverSpy.mock.calls.filter( + (call) => + typeof call[1] === 'object' && + (call[1] as { chatId?: string; text?: string }).chatId === 'guardian-chat-df' && + ((call[1] as { text?: string }).text ?? '').includes('Reply "yes"'), ); - expect(failureCalls.length).toBeGreaterThanOrEqual(1); + expect(guardianPromptCalls.length).toBeGreaterThanOrEqual(1); - // The guardian_request_forwarded success notice should NOT have been - // delivered (since delivery failed). + // Requester should still get the forwarded notice once fallback delivery works. const successCalls = deliverSpy.mock.calls.filter( (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('forwarded'), ); - expect(successCalls.length).toBe(0); + expect(successCalls.length).toBeGreaterThanOrEqual(1); deliverSpy.mockRestore(); approvalSpy.mockRestore(); }); - test('no pending/unresolved approvals remain after delivery failure', async () => { + test('terminal run resolution clears approvals even when rich delivery falls back to text', async () => { createBinding({ assistantId: 'self', channel: 'telegram', @@ -2265,15 +2278,19 @@ describe('guardian delivery failure → denial', () => { await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator); await new Promise((resolve) => setTimeout(resolve, 1200)); + // Rich delivery failure alone should not apply an explicit deny decision. + expect(orchestrator.submitDecision).not.toHaveBeenCalled(); + // Verify the run ID was created const runId = orchestrator.realRunId(); expect(runId).toBeTruthy(); - // After delivery failure, there should be NO pending approval for the run + // This test orchestrator transitions the run to a terminal failed state, + // which resolves the approval record via run-completion cleanup. const pendingApproval = getPendingApprovalForRun(runId!); expect(pendingApproval).toBeNull(); - // There should also be NO unresolved approval (it was set to 'denied') + // No unresolved approval should remain after terminal resolution. const unresolvedApproval = getUnresolvedApprovalForRun(runId!); expect(unresolvedApproval).toBeNull(); @@ -2283,14 +2300,14 @@ describe('guardian delivery failure → denial', () => { }); // ═══════════════════════════════════════════════════════════════════════════ -// 20b. Standard approval prompt delivery failure → auto-deny (WS-B) +// 20b. Standard rich prompt delivery failure → text fallback (WS-B) // ═══════════════════════════════════════════════════════════════════════════ -describe('standard approval prompt delivery failure → auto-deny', () => { +describe('standard approval prompt delivery failure → text fallback', () => { beforeEach(() => { }); - test('standard prompt delivery failure auto-denies the run (fail-closed)', async () => { + test('standard prompt rich-delivery failure falls back to plain text without auto-deny', async () => { const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined); // Make the approval prompt delivery fail for the standard (self-approval) path const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue( @@ -2317,10 +2334,16 @@ describe('standard approval prompt delivery failure → auto-deny', () => { await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator); await new Promise((resolve) => setTimeout(resolve, 1200)); - // The run should have been auto-denied because the prompt could not be delivered - expect(orchestrator.submitDecision).toHaveBeenCalled(); - const decisionArgs = (orchestrator.submitDecision as ReturnType).mock.calls[0]; - expect(decisionArgs[1]).toBe('deny'); + expect(approvalSpy).toHaveBeenCalled(); + expect(orchestrator.submitDecision).not.toHaveBeenCalled(); + + const fallbackCalls = deliverSpy.mock.calls.filter( + (call) => + typeof call[1] === 'object' && + (call[1] as { chatId?: string; text?: string }).chatId === 'chat-123' && + ((call[1] as { text?: string }).text ?? '').includes('Reply "yes"'), + ); + expect(fallbackCalls.length).toBeGreaterThanOrEqual(1); deliverSpy.mockRestore(); approvalSpy.mockRestore(); diff --git a/assistant/src/runtime/approval-message-composer.ts b/assistant/src/runtime/approval-message-composer.ts index dca2941fac7..5a49ec227b1 100644 --- a/assistant/src/runtime/approval-message-composer.ts +++ b/assistant/src/runtime/approval-message-composer.ts @@ -3,10 +3,22 @@ * * Generates approval prompt text through a priority chain: * 1. Assistant preface (macOS parity — reuse existing assistant text) - * 2. Deterministic fallback templates (natural, scenario-specific messages) - * - * A provider-backed generation layer can be inserted between 1 and 2 later. + * 2. Provider-generated rewrite of deterministic fallback text + * 3. Deterministic fallback templates (natural, scenario-specific messages) */ +import { getConfig } from '../config/loader.js'; +import { getFailoverProvider, listProviders } from '../providers/registry.js'; +import type { Provider } from '../providers/types.js'; +import { getLogger } from '../util/logger.js'; + +const log = getLogger('approval-message-composer'); +const APPROVAL_COPY_TIMEOUT_MS = 4_000; +const APPROVAL_COPY_MAX_TOKENS = 180; +const APPROVAL_COPY_SYSTEM_PROMPT = + 'You are an assistant writing one user-facing message about permissions/approval state. ' + + 'Keep it concise, natural, and actionable. Preserve factual details exactly. ' + + 'Do not mention internal systems, scenario IDs, or policy engine details. ' + + 'Return plain text only.'; // --------------------------------------------------------------------------- // Types @@ -48,6 +60,22 @@ export interface ApprovalMessageContext { failureReason?: string; } +export interface ComposeApprovalMessageGenerativeOptions { + /** + * Optional fallback message to use when generation fails. If omitted, + * the deterministic scenario fallback is used. + */ + fallbackText?: string; + /** + * Require these standalone words in the generated output (case-insensitive). + * Useful for plain-text decision flows where parser-compatible keywords + * like yes/no/always must be present. + */ + requiredKeywords?: string[]; + timeoutMs?: number; + maxTokens?: number; +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -65,6 +93,100 @@ export function composeApprovalMessage(context: ApprovalMessageContext): string return getFallbackMessage(context); } +function escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function includesRequiredKeywords(text: string, requiredKeywords: string[] | undefined): boolean { + if (!requiredKeywords || requiredKeywords.length === 0) return true; + return requiredKeywords.every((keyword) => { + const re = new RegExp(`\\b${escapeRegExp(keyword)}\\b`, 'i'); + return re.test(text); + }); +} + +function buildGenerationPrompt( + context: ApprovalMessageContext, + fallbackText: string, + requiredKeywords: string[] | undefined, +): string { + const keywordClause = requiredKeywords && requiredKeywords.length > 0 + ? `Required words to include (as standalone words): ${requiredKeywords.join(', ')}.\n` + : ''; + return [ + 'Rewrite the following approval/guardian message as a natural assistant reply to the user.', + 'Keep the same concrete facts and next-step guidance.', + keywordClause, + `Context JSON: ${JSON.stringify(context)}`, + `Fallback message: ${fallbackText}`, + ].filter(Boolean).join('\n\n'); +} + +async function generateApprovalMessage( + provider: Provider, + context: ApprovalMessageContext, + fallbackText: string, + options: ComposeApprovalMessageGenerativeOptions, +): Promise { + const requiredKeywords = options.requiredKeywords?.map((kw) => kw.trim()).filter((kw) => kw.length > 0); + const prompt = buildGenerationPrompt(context, fallbackText, requiredKeywords); + const response = await provider.sendMessage( + [{ role: 'user', content: [{ type: 'text', text: prompt }] }], + [], + APPROVAL_COPY_SYSTEM_PROMPT, + { + config: { + max_tokens: options.maxTokens ?? APPROVAL_COPY_MAX_TOKENS, + }, + signal: AbortSignal.timeout(options.timeoutMs ?? APPROVAL_COPY_TIMEOUT_MS), + }, + ); + + const block = response.content.find((entry) => entry.type === 'text'); + const text = block && 'text' in block ? block.text.trim() : ''; + if (!text) return null; + const cleaned = text + .replace(/^["'`]+/, '') + .replace(/["'`]+$/, '') + .trim(); + if (!cleaned) return null; + if (!includesRequiredKeywords(cleaned, requiredKeywords)) return null; + return cleaned; +} + +/** + * Compose user-facing approval copy using the active provider when available, + * with deterministic fallback for reliability. + */ +export async function composeApprovalMessageGenerative( + context: ApprovalMessageContext, + options: ComposeApprovalMessageGenerativeOptions = {}, +): Promise { + if (context.assistantPreface && context.assistantPreface.trim().length > 0) { + return context.assistantPreface; + } + + const fallbackText = options.fallbackText?.trim() || getFallbackMessage(context); + + if (process.env.NODE_ENV === 'test') { + return fallbackText; + } + + try { + const config = getConfig(); + if (!listProviders().includes(config.provider)) { + return fallbackText; + } + const provider = getFailoverProvider(config.provider, config.providerOrder); + const generated = await generateApprovalMessage(provider, context, fallbackText, options); + if (generated) return generated; + } catch (err) { + log.warn({ err, scenario: context.scenario }, 'Failed to generate approval copy, using fallback'); + } + + return fallbackText; +} + // --------------------------------------------------------------------------- // Deterministic fallback templates // --------------------------------------------------------------------------- diff --git a/assistant/src/runtime/routes/channel-routes.ts b/assistant/src/runtime/routes/channel-routes.ts index 7141eaf638f..da49ac684c2 100644 --- a/assistant/src/runtime/routes/channel-routes.ts +++ b/assistant/src/runtime/routes/channel-routes.ts @@ -38,14 +38,20 @@ import { buildReminderPrompt, channelSupportsRichApprovalUI, } from '../channel-approvals.js'; -import type { ApprovalAction, ApprovalDecisionResult } from '../channel-approval-types.js'; +import type { + ApprovalAction, + ApprovalDecisionResult, + ApprovalUIMetadata, + ChannelApprovalPrompt, +} from '../channel-approval-types.js'; import type { RunOrchestrator } from '../run-orchestrator.js'; import type { MessageProcessor, RuntimeAttachmentMetadata, } from '../http-types.js'; import type { GuardianRuntimeContext } from '../../daemon/session-runtime-assembly.js'; -import { composeApprovalMessage } from '../approval-message-composer.js'; +import { composeApprovalMessageGenerative } from '../approval-message-composer.js'; +import type { ApprovalMessageContext } from '../approval-message-composer.js'; const log = getLogger('runtime-http'); @@ -129,17 +135,104 @@ function toGuardianRuntimeContext(sourceChannel: string, ctx: GuardianContext): const GUARDIAN_APPROVAL_TTL_MS = 30 * 60 * 1000; /** - * Return the effective prompt text for an approval prompt, appending the - * plainTextFallback instructions when the channel does not support rich - * inline approval UI (e.g. Telegram inline keyboards). + * Keywords the plain-text parser accepts for approval decisions. We require + * these in generated plain-text prompts so text fallback remains actionable. */ -function effectivePromptText( - promptText: string, - plainTextFallback: string, - channel: string, -): string { - if (channelSupportsRichApprovalUI(channel)) return promptText; - return plainTextFallback; +function requiredDecisionKeywords(actions: ApprovalUIMetadata['actions']): string[] { + const hasAlways = actions.some((action) => action.id === 'approve_always'); + return hasAlways ? ['yes', 'always', 'no'] : ['yes', 'no']; +} + +interface DeliverGeneratedApprovalPromptParams { + replyCallbackUrl: string; + chatId: string; + sourceChannel: string; + assistantId: string; + bearerToken?: string; + prompt: ChannelApprovalPrompt; + uiMetadata: ApprovalUIMetadata; + messageContext: ApprovalMessageContext; +} + +/** + * Deliver approval prompts with best-available UX: + * 1) Rich UI (buttons) when supported + * 2) Plain-text fallback if rich delivery fails + * 3) Plain-text path for channels without rich UI + */ +async function deliverGeneratedApprovalPrompt(params: DeliverGeneratedApprovalPromptParams): Promise { + const { + replyCallbackUrl, + chatId, + sourceChannel, + assistantId, + bearerToken, + prompt, + uiMetadata, + messageContext, + } = params; + const keywords = requiredDecisionKeywords(uiMetadata.actions); + + if (channelSupportsRichApprovalUI(sourceChannel)) { + const richText = await composeApprovalMessageGenerative( + { ...messageContext, channel: sourceChannel, richUi: true }, + { fallbackText: prompt.promptText }, + ); + + try { + await deliverApprovalPrompt( + replyCallbackUrl, + chatId, + richText, + uiMetadata, + assistantId, + bearerToken, + ); + return true; + } catch (err) { + log.error( + { err, chatId, sourceChannel }, + 'Failed to deliver rich approval prompt, attempting plain-text fallback', + ); + } + + const plainTextFallback = await composeApprovalMessageGenerative( + { ...messageContext, channel: sourceChannel, richUi: false }, + { fallbackText: prompt.plainTextFallback, requiredKeywords: keywords }, + ); + + try { + await deliverChannelReply(replyCallbackUrl, { + chatId, + text: plainTextFallback, + assistantId, + }, bearerToken); + return true; + } catch (err) { + log.error( + { err, chatId, sourceChannel }, + 'Failed to deliver plain-text fallback approval prompt', + ); + return false; + } + } + + const plainText = await composeApprovalMessageGenerative( + { ...messageContext, channel: sourceChannel, richUi: false }, + { fallbackText: prompt.plainTextFallback, requiredKeywords: keywords }, + ); + + try { + await deliverChannelReply(replyCallbackUrl, { + chatId, + text: plainText, + assistantId, + }, bearerToken); + return true; + } catch (err) { + log.error({ err, chatId, sourceChannel }, 'Failed to deliver plain-text approval prompt'); + return false; + } } /** @@ -150,13 +243,22 @@ function effectivePromptText( function buildGuardianDenyContext( toolName: string, denialReason: DenialReason, - sourceChannel: string, + _sourceChannel: string, ): string { if (denialReason === 'no_identity') { - return `Permission denied: ${composeApprovalMessage({ scenario: 'guardian_deny_no_identity', toolName, channel: sourceChannel })} Do not retry yet. Ask the user to message from a verifiable direct account/chat, and then retry after identity is available.`; + return `Permission denied for "${toolName}": guardian approval was required, but requester identity could not be verified for this channel. In your next assistant reply, explain this clearly, avoid retrying yet, and ask the user to message from a verifiable direct account/chat before retrying.`; } - return `Permission denied: ${composeApprovalMessage({ scenario: 'guardian_deny_no_binding', toolName, channel: sourceChannel })} Do not retry yet. Offer to set up guardian verification. The setup flow will provide a verification token to send as /guardian_verify .`; + return `Permission denied for "${toolName}": guardian approval was required, but no guardian is configured for this channel. In your next assistant reply, explain this and offer guardian setup. Mention that setup provides a verification token to send as /guardian_verify .`; +} + +function buildPromptDeliveryFailureContext(toolName: string): string { + return `Permission denied for "${toolName}": approval UI delivery failed and no plain-text fallback could be delivered. In your next assistant reply, apologize briefly, explain approval delivery failed, and ask the user to retry.`; +} + +function stripVerificationFailurePrefix(reason: string): string { + const trimmed = reason.trim(); + return trimmed.replace(/^verification failed\.?\s*/i, '').trim() || trimmed; } // --------------------------------------------------------------------------- @@ -416,8 +518,15 @@ export async function handleChannelInbound( ); const replyText = verifyResult.success - ? composeApprovalMessage({ scenario: 'guardian_verify_success' }) - : verifyResult.reason; + ? await composeApprovalMessageGenerative({ + scenario: 'guardian_verify_success', + channel: sourceChannel, + }) + : await composeApprovalMessageGenerative({ + scenario: 'guardian_verify_failed', + channel: sourceChannel, + failureReason: stripVerificationFailurePrefix(verifyResult.reason), + }); try { await deliverChannelReply(replyCallbackUrl, { @@ -843,48 +952,48 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS, }); - let guardianNotified = false; - try { - const guardianText = effectivePromptText( - guardianPrompt.promptText, - guardianPrompt.plainTextFallback, - sourceChannel, - ); - await deliverApprovalPrompt( - replyCallbackUrl, - guardianCtx.guardianChatId, - guardianText, - uiMetadata, - assistantId, - bearerToken, - ); - guardianNotified = true; + const guardianNotified = await deliverGeneratedApprovalPrompt({ + replyCallbackUrl, + chatId: guardianCtx.guardianChatId, + sourceChannel, + assistantId, + bearerToken, + prompt: guardianPrompt, + uiMetadata, + messageContext: { + scenario: 'guardian_prompt', + toolName: pending[0].toolName, + requesterIdentifier: guardianCtx.requesterIdentifier ?? 'Unknown user', + }, + }); + + if (guardianNotified) { hasPostDecisionDelivery = true; - } catch (err) { - log.error({ err, runId: run.id }, 'Failed to deliver guardian approval prompt'); + } else { // Deny the approval and the underlying run — fail-closed. If // the prompt could not be delivered, the guardian will never see // it, so the safest action is to deny rather than cancel (which // would allow requester fallback). updateApprovalDecision(approvalReqRecord.id, { status: 'denied' }); - handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator); - try { - await deliverChannelReply(replyCallbackUrl, { - chatId: guardianCtx.requesterChatId ?? externalChatId, - text: composeApprovalMessage({ scenario: 'guardian_delivery_failed', toolName: pending[0].toolName }), - assistantId, - }, bearerToken); - } catch (notifyErr) { - log.error({ err: notifyErr, runId: run.id }, 'Failed to notify requester of guardian delivery failure'); - } + handleChannelDecision( + conversationId, + { action: 'reject', source: 'plain_text' }, + orchestrator, + buildPromptDeliveryFailureContext(pending[0].toolName), + ); } // Only notify the requester if the guardian prompt was actually delivered if (guardianNotified) { try { + const forwardedText = await composeApprovalMessageGenerative({ + scenario: 'guardian_request_forwarded', + toolName: pending[0].toolName, + channel: sourceChannel, + }); await deliverChannelReply(replyCallbackUrl, { chatId: guardianCtx.requesterChatId ?? externalChatId, - text: composeApprovalMessage({ scenario: 'guardian_request_forwarded', toolName: pending[0].toolName }), + text: forwardedText, assistantId, }, bearerToken); } catch (err) { @@ -897,30 +1006,35 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v const prompt = getChannelApprovalPrompt(conversationId); if (prompt && pending.length > 0) { const uiMetadata = buildApprovalUIMetadata(prompt, pending[0]); - try { - const promptTextForChannel = effectivePromptText( - prompt.promptText, - prompt.plainTextFallback, - sourceChannel, - ); - await deliverApprovalPrompt( - replyCallbackUrl, - externalChatId, - promptTextForChannel, - uiMetadata, - assistantId, - bearerToken, - ); + const delivered = await deliverGeneratedApprovalPrompt({ + replyCallbackUrl, + chatId: externalChatId, + sourceChannel, + assistantId, + bearerToken, + prompt, + uiMetadata, + messageContext: { + scenario: 'standard_prompt', + toolName: pending[0].toolName, + }, + }); + if (delivered) { hasPostDecisionDelivery = true; - } catch (err) { + } else { // Fail-closed: if we cannot deliver the approval prompt, the // user will never see it and the run would hang indefinitely // in needs_confirmation. Auto-deny to avoid silent wait states. log.error( - { err, runId: run.id, conversationId }, + { runId: run.id, conversationId }, 'Failed to deliver standard approval prompt; auto-denying (fail-closed)', ); - handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator); + handleChannelDecision( + conversationId, + { action: 'reject', source: 'plain_text' }, + orchestrator, + buildPromptDeliveryFailureContext(pending[0].toolName), + ); } } } @@ -1109,9 +1223,14 @@ async function handleApprovalInterception( const allPending = getAllPendingApprovalsByGuardianChat(sourceChannel, externalChatId, assistantId); if (allPending.length > 1) { try { + const disambiguationText = await composeApprovalMessageGenerative({ + scenario: 'guardian_disambiguation', + pendingCount: allPending.length, + channel: sourceChannel, + }); await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, - text: composeApprovalMessage({ scenario: 'guardian_disambiguation', pendingCount: allPending.length }), + text: disambiguationText, assistantId, }, bearerToken); } catch (err) { @@ -1142,9 +1261,13 @@ async function handleApprovalInterception( 'Non-guardian sender attempted to act on guardian approval request', ); try { + const mismatchText = await composeApprovalMessageGenerative({ + scenario: 'guardian_identity_mismatch', + channel: sourceChannel, + }); await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, - text: composeApprovalMessage({ scenario: 'guardian_identity_mismatch' }), + text: mismatchText, assistantId, }, bearerToken); } catch (err) { @@ -1178,10 +1301,11 @@ async function handleApprovalInterception( if (result.applied) { // Notify the requester's chat about the outcome with the tool name - const outcomeText = composeApprovalMessage({ + const outcomeText = await composeApprovalMessageGenerative({ scenario: 'guardian_decision_outcome', decision: decision.action === 'reject' ? 'denied' : 'approved', toolName: guardianApproval.toolName, + channel: sourceChannel, }); try { await deliverChannelReply(replyCallbackUrl, { @@ -1221,19 +1345,26 @@ async function handleApprovalInterception( const reminder = buildReminderPrompt(guardianPrompt); const uiMetadata = buildApprovalUIMetadata(reminder, pendingInfo[0]); try { - const reminderText = effectivePromptText( - reminder.promptText, - reminder.plainTextFallback, - sourceChannel, - ); - await deliverApprovalPrompt( + const delivered = await deliverGeneratedApprovalPrompt({ replyCallbackUrl, - externalChatId, - reminderText, - uiMetadata, + chatId: externalChatId, + sourceChannel, assistantId, bearerToken, - ); + prompt: reminder, + uiMetadata, + messageContext: { + scenario: 'reminder_prompt', + channel: sourceChannel, + toolName: pendingInfo[0].toolName, + }, + }); + if (!delivered) { + log.error( + { conversationId: guardianApproval.conversationId, externalChatId }, + 'Failed to deliver guardian approval reminder', + ); + } } catch (err) { log.error({ err, conversationId: guardianApproval.conversationId }, 'Failed to deliver guardian approval reminder'); } @@ -1286,9 +1417,13 @@ async function handleApprovalInterception( const guardianApprovalForRun = getPendingApprovalForRun(pending[0].runId); if (guardianApprovalForRun) { try { + const pendingText = await composeApprovalMessageGenerative({ + scenario: 'request_pending_guardian', + channel: sourceChannel, + }); await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, - text: composeApprovalMessage({ scenario: 'request_pending_guardian' }), + text: pendingText, assistantId, }, bearerToken); } catch (err) { @@ -1313,9 +1448,14 @@ async function handleApprovalInterception( handleChannelDecision(conversationId, expiredDecision, orchestrator); try { + const expiredText = await composeApprovalMessageGenerative({ + scenario: 'guardian_expired_requester', + toolName: pending[0].toolName, + channel: sourceChannel, + }); await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, - text: composeApprovalMessage({ scenario: 'guardian_expired_requester', toolName: pending[0].toolName }), + text: expiredText, assistantId, }, bearerToken); } catch (err) { @@ -1381,19 +1521,23 @@ async function handleApprovalInterception( if (pending.length > 0) { const uiMetadata = buildApprovalUIMetadata(reminder, pending[0]); try { - const reminderText = effectivePromptText( - reminder.promptText, - reminder.plainTextFallback, - sourceChannel, - ); - await deliverApprovalPrompt( + const delivered = await deliverGeneratedApprovalPrompt({ replyCallbackUrl, - externalChatId, - reminderText, - uiMetadata, + chatId: externalChatId, + sourceChannel, assistantId, bearerToken, - ); + prompt: reminder, + uiMetadata, + messageContext: { + scenario: 'reminder_prompt', + channel: sourceChannel, + toolName: pending[0].toolName, + }, + }); + if (!delivered) { + log.error({ conversationId, externalChatId }, 'Failed to deliver approval reminder'); + } } catch (err) { log.error({ err, conversationId }, 'Failed to deliver approval reminder'); } @@ -1582,20 +1726,35 @@ export function sweepExpiredGuardianApprovals( const deliverUrl = `${gatewayBaseUrl}/deliver/${approval.channel}`; // Notify the requester that the approval expired - deliverChannelReply(deliverUrl, { - chatId: approval.requesterChatId, - text: composeApprovalMessage({ scenario: 'guardian_expired_requester', toolName: approval.toolName }), - assistantId: approval.assistantId, - }, bearerToken).catch((err) => { + void (async () => { + const requesterText = await composeApprovalMessageGenerative({ + scenario: 'guardian_expired_requester', + toolName: approval.toolName, + channel: approval.channel, + }); + await deliverChannelReply(deliverUrl, { + chatId: approval.requesterChatId, + text: requesterText, + assistantId: approval.assistantId, + }, bearerToken); + })().catch((err) => { log.error({ err, runId: approval.runId }, 'Failed to notify requester of guardian approval expiry'); }); // Notify the guardian that the approval expired - deliverChannelReply(deliverUrl, { - chatId: approval.guardianChatId, - text: composeApprovalMessage({ scenario: 'guardian_expired_guardian', toolName: approval.toolName, requesterIdentifier: approval.requesterExternalUserId }), - assistantId: approval.assistantId, - }, bearerToken).catch((err) => { + void (async () => { + const guardianText = await composeApprovalMessageGenerative({ + scenario: 'guardian_expired_guardian', + toolName: approval.toolName, + requesterIdentifier: approval.requesterExternalUserId, + channel: approval.channel, + }); + await deliverChannelReply(deliverUrl, { + chatId: approval.guardianChatId, + text: guardianText, + assistantId: approval.assistantId, + }, bearerToken); + })().catch((err) => { log.error({ err, runId: approval.runId }, 'Failed to notify guardian of approval expiry'); });