From 5c3f9efbda6de02f66c51d0df1127c25bc3a6e74 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Mon, 23 Feb 2026 22:27:29 -0500 Subject: [PATCH] fix: include runId in plain-text approval fallback for disambiguation Co-Authored-By: Claude --- .../src/__tests__/channel-approval.test.ts | 37 +++++++++++++++++++ .../src/runtime/channel-approval-parser.ts | 33 ++++++++++++++++- .../src/runtime/routes/channel-routes.ts | 11 +++++- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/assistant/src/__tests__/channel-approval.test.ts b/assistant/src/__tests__/channel-approval.test.ts index b98a38da2f1..12626ca6c6f 100644 --- a/assistant/src/__tests__/channel-approval.test.ts +++ b/assistant/src/__tests__/channel-approval.test.ts @@ -195,6 +195,43 @@ describe('parseApprovalDecision', () => { ])('returns null for non-matching text: "%s"', (input) => { expect(parseApprovalDecision(input)).toBeNull(); }); + + // ── Run-reference tag extraction ──────────────────────────────── + + test('extracts runId from [ref:...] tag with approve decision', () => { + const result = parseApprovalDecision('yes [ref:run-abc-123]'); + expect(result).not.toBeNull(); + expect(result!.action).toBe('approve_once'); + expect(result!.source).toBe('plain_text'); + expect(result!.runId).toBe('run-abc-123'); + }); + + test('extracts runId from [ref:...] tag with reject decision', () => { + const result = parseApprovalDecision('no [ref:run-xyz-456]'); + expect(result).not.toBeNull(); + expect(result!.action).toBe('reject'); + expect(result!.runId).toBe('run-xyz-456'); + }); + + test('extracts runId from [ref:...] tag with always decision', () => { + const result = parseApprovalDecision('always [ref:run-789]'); + expect(result).not.toBeNull(); + expect(result!.action).toBe('approve_always'); + expect(result!.runId).toBe('run-789'); + }); + + test('handles ref tag on separate line', () => { + const result = parseApprovalDecision('yes\n[ref:run-abc-123]'); + expect(result).not.toBeNull(); + expect(result!.action).toBe('approve_once'); + expect(result!.runId).toBe('run-abc-123'); + }); + + test('decision without ref tag has no runId', () => { + const result = parseApprovalDecision('yes'); + expect(result).not.toBeNull(); + expect(result!.runId).toBeUndefined(); + }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/assistant/src/runtime/channel-approval-parser.ts b/assistant/src/runtime/channel-approval-parser.ts index 11b7a639683..82827ef59bf 100644 --- a/assistant/src/runtime/channel-approval-parser.ts +++ b/assistant/src/runtime/channel-approval-parser.ts @@ -44,6 +44,30 @@ const PHRASE_MAP = buildPhraseMap(); // Public API // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Run-reference tag extraction +// --------------------------------------------------------------------------- + +/** + * Pattern matching a `[ref:]` disambiguation tag appended to + * plain-text approval prompts. Guardians can include this tag in their + * reply so that `handleApprovalInterception` can resolve the correct + * pending approval when multiple approvals target the same chat. + */ +const REF_TAG_RE = /\[ref:([^\]]+)\]/i; + +/** + * Extract a run-reference tag from the text and return the cleaned + * decision text plus the extracted runId (if any). + */ +function extractRefTag(text: string): { cleaned: string; runId?: string } { + const match = REF_TAG_RE.exec(text); + if (!match) return { cleaned: text }; + const runId = match[1].trim(); + const cleaned = text.replace(REF_TAG_RE, '').trim(); + return { cleaned, runId: runId || undefined }; +} + /** * Parse a plain-text message into an approval decision. * @@ -51,10 +75,15 @@ const PHRASE_MAP = buildPhraseMap(); * of the known intent phrases, or `null` if it does not match. * * Matching is case-insensitive with leading/trailing whitespace trimmed. + * + * When the text contains a `[ref:]` tag (appended by the + * plain-text fallback path), the extracted runId is included in the + * result so the caller can disambiguate among multiple pending approvals. */ export function parseApprovalDecision(text: string): ApprovalDecisionResult | null { - const normalised = text.trim().toLowerCase(); + const { cleaned, runId } = extractRefTag(text); + const normalised = cleaned.trim().toLowerCase(); const action = PHRASE_MAP.get(normalised); if (!action) return null; - return { action, source: 'plain_text' }; + return { action, source: 'plain_text', ...(runId ? { runId } : {}) }; } diff --git a/assistant/src/runtime/routes/channel-routes.ts b/assistant/src/runtime/routes/channel-routes.ts index 33f6e2005e3..d8f3c098a16 100644 --- a/assistant/src/runtime/routes/channel-routes.ts +++ b/assistant/src/runtime/routes/channel-routes.ts @@ -201,10 +201,14 @@ async function deliverGeneratedApprovalPrompt(params: DeliverGeneratedApprovalPr { fallbackText: prompt.plainTextFallback, requiredKeywords: keywords }, ); + // Embed the run reference so plain-text replies can disambiguate when + // multiple approvals are pending for the same guardian chat. + const taggedFallback = `${plainTextFallback}\n[ref:${uiMetadata.runId}]`; + try { await deliverChannelReply(replyCallbackUrl, { chatId, - text: plainTextFallback, + text: taggedFallback, assistantId, }, bearerToken); return true; @@ -222,10 +226,13 @@ async function deliverGeneratedApprovalPrompt(params: DeliverGeneratedApprovalPr { fallbackText: prompt.plainTextFallback, requiredKeywords: keywords }, ); + // Embed the run reference for disambiguation in multi-pending scenarios. + const taggedPlainText = `${plainText}\n[ref:${uiMetadata.runId}]`; + try { await deliverChannelReply(replyCallbackUrl, { chatId, - text: plainText, + text: taggedPlainText, assistantId, }, bearerToken); return true;