diff --git a/packages/core/src/reply-engine/classifier.ts b/packages/core/src/reply-engine/classifier.ts new file mode 100644 index 0000000..c1abf29 --- /dev/null +++ b/packages/core/src/reply-engine/classifier.ts @@ -0,0 +1,27 @@ +import type { A2HMessage, ChatSDKMessage, MessageItem } from '../types/index.js'; + +/** + * Type guard: returns true when `item` is an A2H message. + * + * Classification is done by duck typing per the spec: + * presence of `intent` field → A2H protocol message + * otherwise → Vercel Chat SDK message + */ +export function isA2HMessage(item: MessageItem): item is A2HMessage { + return ( + typeof item === 'object' && + item !== null && + 'intent' in item && + typeof (item as A2HMessage).intent === 'string' + ); +} + +/** Inverse of isA2HMessage. */ +export function isChatSDKMessage(item: MessageItem): item is ChatSDKMessage { + return !isA2HMessage(item); +} + +/** Returns a discriminated label for use in conditional logic. */ +export function classifyMessage(item: MessageItem): 'a2h' | 'chatSdk' { + return isA2HMessage(item) ? 'a2h' : 'chatSdk'; +} diff --git a/packages/core/src/reply-engine/index.ts b/packages/core/src/reply-engine/index.ts new file mode 100644 index 0000000..8c725e1 --- /dev/null +++ b/packages/core/src/reply-engine/index.ts @@ -0,0 +1,291 @@ +import type { + A2HMessage, + A2HResponse, + ChannelAdapter, + ChatSDKMessage, + EscalationHandler, + ReplyEngineConfig, + ReplyEngineResult, + ReplyEnvelope, +} from '../types/index.js'; +import { normalizeMessage } from './normalizer.js'; +import { isA2HMessage, isChatSDKMessage } from './classifier.js'; +import { + selectReplyMethod, + selectBatchMethod, + resolveCaptureMethod, +} from './method-selector.js'; +import { + ResponseRegistry, + TimeoutError, + withTimeout, +} from './response-collector.js'; + +export { normalizeMessage } from './normalizer.js'; +export { isA2HMessage, isChatSDKMessage, classifyMessage } from './classifier.js'; +export { selectReplyMethod, selectBatchMethod, resolveCaptureMethod } from './method-selector.js'; +export { ResponseRegistry, TimeoutError, withTimeout } from './response-collector.js'; + +/** Default timeout: 5 minutes */ +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_FORM_BASE_URL = 'https://openthreads.host/form'; + +/** + * Reply Engine — the core component that processes the recipient inbound envelope. + * + * Responsibilities: + * 1. Parse the `message` field and normalize to an array. + * 2. Classify each item (Chat SDK vs A2H) via duck typing. + * 3. For Chat SDK items: delegate to `ChannelAdapter.renderChatSDK()`. + * 4. For A2H items: select the best reply method and execute it. + * 5. Block on blocking intents (COLLECT, AUTHORIZE, ESCALATE) until a + * human responds, then return responses in the same order as the intents. + * + * ### Method selection decision tree + * + * ``` + * Trust layer active? → method 3 (external form, required for strong auth) + * AUTHORIZE → method 1 (inline) if buttons, else method 3 + * COLLECT closed options → method 1 if select/buttons, else method 3 + * COLLECT free-text (1 field) → method 2 (capture hierarchy), fallback method 3 + * COLLECT multiple fields → method 3 + * Multiple A2H intents → method 4 (batch form) + * INFORM → fire-and-forget (plain message, no blocking) + * ESCALATE → escalation handler if configured, else method 3 + * ``` + * + * ### Form responses (methods 3 & 4) + * + * The Reply Engine blocks until the human submits the external form. Use + * `replyEngine.registry.submit(formKey, response)` to deliver the human's + * answer from the form server webhook. The form key is `${turnId}` for single + * intents and `${turnId}_batch` for batched intents. + * + * @example + * ```ts + * const engine = new ReplyEngine(slackAdapter, { timeoutMs: 60_000 }); + * + * // Process inbound reply from recipient system + * const result = await engine.process( + * { message: [{ text: 'Done!' }, { intent: 'AUTHORIZE', context: { action: 'deploy' } }] }, + * 'ot_turn_001', + * ); + * + * // result.responses[0] === null (Chat SDK message, no response needed) + * // result.responses[1] === { intent: 'AUTHORIZE', response: true, ... } + * ``` + */ +export class ReplyEngine { + /** Registry for pending external form responses (methods 3 & 4). */ + readonly registry: ResponseRegistry; + + private readonly config: Required> & { + escalationHandler: EscalationHandler | null; + }; + + constructor( + private readonly adapter: ChannelAdapter, + config: ReplyEngineConfig = {}, + ) { + this.config = { + timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS, + trustLayerActive: config.trustLayerActive ?? false, + formBaseUrl: config.formBaseUrl ?? DEFAULT_FORM_BASE_URL, + escalationHandler: config.escalationHandler ?? null, + }; + this.registry = new ResponseRegistry(); + } + + /** + * Process a recipient inbound envelope and return the collected responses. + * + * @param envelope The inbound JSON body from the recipient system. + * @param turnId The turn identifier assigned by the Router. Used as the form key. + */ + async process(envelope: ReplyEnvelope, turnId: string): Promise { + const items = normalizeMessage(envelope.message); + + // Partition items: Chat SDK messages and A2H intents. + const a2hItems = items.filter(isA2HMessage); + + // When there are multiple A2H intents, batch all of them to method 4. + if (a2hItems.length > 1) { + return this.processMixed(items, a2hItems, turnId); + } + + // Single A2H intent (or none): process sequentially. + const responses: (A2HResponse | null)[] = []; + for (const item of items) { + if (isChatSDKMessage(item)) { + await this.adapter.renderChatSDK(item as ChatSDKMessage); + responses.push(null); + } else { + const response = await this.processA2HItem(item as A2HMessage, turnId); + responses.push(response); + } + } + + return { responses }; + } + + // --------------------------------------------------------------------------- + // Private — message processing + // --------------------------------------------------------------------------- + + /** + * Process a mixed array where multiple A2H intents require method 4 (batch form). + * Chat SDK items are sent first in order, then all A2H items are batched. + */ + private async processMixed( + items: Array, + a2hItems: A2HMessage[], + turnId: string, + ): Promise { + const responses: (A2HResponse | null)[] = new Array(items.length).fill(null); + + // Send Chat SDK items first (they appear in document order). + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (isChatSDKMessage(item)) { + await this.adapter.renderChatSDK(item as ChatSDKMessage); + // responses[i] remains null + } + } + + // Batch all A2H intents into method 4. + const batchKey = `${turnId}_batch`; + const batchFormUrl = this.generateFormUrl(batchKey); + await this.adapter.sendFormLink(batchFormUrl, a2hItems); + + // Block until the human submits the batch form. + const batchResponses = await this.waitForBatchResponse(batchKey, a2hItems, turnId); + + // Distribute batch responses back into the original positions. + let batchIdx = 0; + for (let i = 0; i < items.length; i++) { + if (isA2HMessage(items[i])) { + responses[i] = batchResponses[batchIdx++] ?? null; + } + } + + return { responses }; + } + + /** Process a single A2H item using the selected reply method. */ + private async processA2HItem(item: A2HMessage, turnId: string): Promise { + // INFORM is fire-and-forget — render as plain message, no response needed. + if (item.intent === 'INFORM') { + const text = item.context?.details ?? item.context?.action ?? ''; + await this.adapter.renderChatSDK({ text }); + return null; + } + + // ESCALATE has a dedicated handler path. + if (item.intent === 'ESCALATE') { + return this.processEscalate(item, turnId); + } + + const method = selectReplyMethod( + item, + this.adapter.capabilities, + this.config.trustLayerActive, + ); + + switch (method) { + case 1: + return this.withTimeout( + this.adapter.renderA2HInline(item), + item, + turnId, + ); + + case 2: + return this.processMethod2(item, turnId); + + case 3: + return this.processMethod3(item, turnId); + + default: + return this.processMethod3(item, turnId); + } + } + + // --------------------------------------------------------------------------- + // Private — reply methods + // --------------------------------------------------------------------------- + + /** Method 2: text capture via the channel's native affordances. */ + private async processMethod2(item: A2HMessage, turnId: string): Promise { + const captureMethod = resolveCaptureMethod(this.adapter.capabilities); + + if (captureMethod === 'none') { + // Channel can't capture free-text natively — fall back to method 3. + return this.processMethod3(item, turnId); + } + + return this.withTimeout( + this.adapter.captureResponse(item, captureMethod), + item, + turnId, + ); + } + + /** + * Method 3: generate a temporary form URL, send the link in the channel, + * and block until the human submits the form. + */ + private async processMethod3(item: A2HMessage, turnId: string): Promise { + const formUrl = this.generateFormUrl(turnId); + await this.adapter.sendFormLink(formUrl, item); + + // Block until the form server delivers the response via registry.submit(). + const pendingPromise = this.registry.wait(turnId, item.intent); + return this.withTimeout(pendingPromise, item, turnId); + } + + /** ESCALATE: delegate to the configured escalation handler, or fall back to method 3. */ + private async processEscalate(item: A2HMessage, turnId: string): Promise { + if (this.config.escalationHandler) { + return this.withTimeout( + this.config.escalationHandler.handle(item), + item, + turnId, + ); + } + return this.processMethod3(item, turnId); + } + + /** + * Wait for batch form responses (method 4). + * Each A2H intent in the batch gets a sub-key `${batchKey}_${i}`. + * The form server must call `registry.submit()` for each sub-key. + */ + private async waitForBatchResponse( + batchKey: string, + a2hItems: A2HMessage[], + turnId: string, + ): Promise { + const pending = a2hItems.map((item, i) => { + const subKey = `${batchKey}_${i}`; + const promise = this.registry.wait(subKey, item.intent); + return this.withTimeout(promise, item, turnId); + }); + return Promise.all(pending); + } + + // --------------------------------------------------------------------------- + // Private — utilities + // --------------------------------------------------------------------------- + + private generateFormUrl(key: string): string { + return `${this.config.formBaseUrl}/${key}`; + } + + private withTimeout( + promise: Promise, + item: A2HMessage, + turnId: string, + ): Promise { + return withTimeout(promise, this.config.timeoutMs, item.intent, turnId); + } +} diff --git a/packages/core/src/reply-engine/method-selector.ts b/packages/core/src/reply-engine/method-selector.ts new file mode 100644 index 0000000..71ad285 --- /dev/null +++ b/packages/core/src/reply-engine/method-selector.ts @@ -0,0 +1,123 @@ +import type { A2HMessage, ChannelCapabilities, CaptureMethod, ReplyMethod } from '../types/index.js'; + +/** + * Select the appropriate reply method for a single A2H intent. + * + * Decision tree (from VISION.md — Automatic selection logic): + * + * Trust layer active? → method 3 (always — required for strong auth) + * Simple AUTHORIZE + * └─ channel supports buttons? → method 1 (inline) + * └─ otherwise → method 3 (external form) + * COLLECT with closed options (select/multiselect/checkbox) + * └─ channel supports select/buttons? → method 1 (inline) + * └─ otherwise → method 3 (external form) + * Free-text COLLECT (1 text/textarea field or no fields with a question) + * └─ see selectCaptureHierarchy() — returns method 2 or 3 + * COLLECT with multiple fields → method 3 (external form) + * INFORM → method 1 (fire-and-forget, rendered as plain message) + * ESCALATE → caller should use the escalation handler; returns method 3 as fallback + * + * For arrays with multiple A2H intents, call selectBatchMethod() instead. + * + * @param item Single A2H message to evaluate. + * @param capabilities Capabilities of the destination channel. + * @param trustLayerActive When true, always forces method 3. + */ +export function selectReplyMethod( + item: A2HMessage, + capabilities: ChannelCapabilities, + trustLayerActive: boolean, +): ReplyMethod { + // Trust layer is active — only external form supports strong authentication. + if (trustLayerActive) { + return 3; + } + + switch (item.intent) { + case 'INFORM': + // Fire-and-forget: rendered as a plain channel message (uses Chat SDK path). + return 1; + + case 'AUTHORIZE': + // Simple approve/deny — uses inline buttons when the channel supports them. + return capabilities.supportsButtons ? 1 : 3; + + case 'COLLECT': + return selectCollectMethod(item, capabilities); + + case 'ESCALATE': + // ESCALATE is handled by an optional escalation handler in ReplyEngine. + // This method only determines the fallback when no handler is configured. + return 3; + + case 'RESULT': + // RESULT is an outbound-only intent (agent sending a result to the human). + // Treat as a plain message. + return 1; + + default: + return 3; + } +} + +/** + * Select method 4 when the message array contains multiple A2H intents. + * Method 4 groups all intents into a single external form page. + */ +export function selectBatchMethod(): ReplyMethod { + return 4; +} + +/** + * Determine the capture hierarchy for method 2 (text capture). + * + * Hierarchy (most to least explicit): + * 1. Native thread (Slack, Discord) + * 2. Native reply (Telegram in groups, WhatsApp) + * 3. DM (next message from sender = response) + * 4. None → caller should fall back to method 3 + */ +export function resolveCaptureMethod(capabilities: ChannelCapabilities): CaptureMethod { + if (capabilities.supportsNativeThreads) return 'thread'; + if (capabilities.supportsNativeReplies) return 'reply'; + if (capabilities.isDM) return 'dm'; + return 'none'; +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +function selectCollectMethod(item: A2HMessage, capabilities: ChannelCapabilities): ReplyMethod { + const fields = item.collect?.fields ?? []; + + if (fields.length > 1) { + // Multiple fields → external form (can't render a multi-field survey inline). + return 3; + } + + if (fields.length === 0) { + // No fields defined: the intent carries a free-text question. + return selectFreeTextMethod(capabilities); + } + + const [field] = fields; + const isClosedOption = + field.type === 'select' || + field.type === 'multiselect' || + field.type === 'checkbox'; + + if (isClosedOption) { + // Closed options can be rendered as buttons or select menus inline. + return capabilities.supportsSelectMenus || capabilities.supportsButtons ? 1 : 3; + } + + // Single free-text field (text / textarea / date / number). + return selectFreeTextMethod(capabilities); +} + +function selectFreeTextMethod(capabilities: ChannelCapabilities): ReplyMethod { + const captureMethod = resolveCaptureMethod(capabilities); + return captureMethod === 'none' ? 3 : 2; +} diff --git a/packages/core/src/reply-engine/normalizer.ts b/packages/core/src/reply-engine/normalizer.ts new file mode 100644 index 0000000..a256ac0 --- /dev/null +++ b/packages/core/src/reply-engine/normalizer.ts @@ -0,0 +1,15 @@ +import type { MessageItem, ReplyEnvelope } from '../types/index.js'; + +/** + * Normalize the `message` field from the recipient inbound envelope. + * + * The spec allows `message` to be either a single object or an array. + * This function always returns an array of 1 or more items so the rest of the + * Reply Engine can iterate uniformly. + */ +export function normalizeMessage(message: ReplyEnvelope['message']): MessageItem[] { + if (Array.isArray(message)) { + return message; + } + return [message]; +} diff --git a/packages/core/src/reply-engine/response-collector.ts b/packages/core/src/reply-engine/response-collector.ts new file mode 100644 index 0000000..50afd80 --- /dev/null +++ b/packages/core/src/reply-engine/response-collector.ts @@ -0,0 +1,116 @@ +import type { A2HResponse, A2HIntent } from '../types/index.js'; + +/** + * Error thrown when a blocking A2H intent times out waiting for a human response. + */ +export class TimeoutError extends Error { + constructor( + public readonly timeoutMs: number, + public readonly intent: A2HIntent, + public readonly turnId: string, + ) { + super( + `A2H intent "${intent}" for turn "${turnId}" timed out after ${timeoutMs}ms with no response`, + ); + this.name = 'TimeoutError'; + } +} + +/** + * Wrap a promise with a deadline. + * + * Rejects with `TimeoutError` if the promise does not settle within `timeoutMs`. + */ +export function withTimeout( + promise: Promise, + timeoutMs: number, + intent: A2HIntent, + turnId: string, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new TimeoutError(timeoutMs, intent, turnId)); + }, timeoutMs); + + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +/** + * PendingResponse tracks an in-flight A2H interaction that is waiting for the + * human to respond (via form submission, button click, etc.). + * + * The Reply Engine stores one PendingResponse per blocking A2H item, keyed by + * turnId + index. When the form server (or channel adapter) receives the + * human's answer, it calls `resolve()` to unblock the engine. + */ +export interface PendingResponse { + resolve: (response: A2HResponse) => void; + reject: (error: unknown) => void; + intent: A2HIntent; + createdAt: Date; +} + +/** + * Registry of pending form responses. + * + * The Reply Engine registers a pending entry when it sends a method-3 form link. + * The form server calls `ResponseRegistry.submit()` when the human submits the form, + * which resolves the corresponding promise and unblocks the Reply Engine. + */ +export class ResponseRegistry { + private readonly pending = new Map(); + + /** + * Create a pending entry for `key` and return a Promise that resolves when + * `submit(key, response)` is called. + */ + wait(key: string, intent: A2HIntent): Promise { + return new Promise((resolve, reject) => { + this.pending.set(key, { resolve, reject, intent, createdAt: new Date() }); + }); + } + + /** + * Resolve the pending entry for `key` with the human's response. + * Returns true if a pending entry was found and resolved, false otherwise. + */ + submit(key: string, response: A2HResponse): boolean { + const entry = this.pending.get(key); + if (!entry) return false; + this.pending.delete(key); + entry.resolve(response); + return true; + } + + /** + * Reject the pending entry for `key` (e.g., form expired or cancelled). + * Returns true if a pending entry was found and rejected, false otherwise. + */ + cancel(key: string, reason?: unknown): boolean { + const entry = this.pending.get(key); + if (!entry) return false; + this.pending.delete(key); + entry.reject(reason ?? new Error(`Pending response for key "${key}" was cancelled`)); + return true; + } + + /** Returns the number of currently pending entries. */ + get size(): number { + return this.pending.size; + } + + /** Check whether a pending entry exists for `key`. */ + has(key: string): boolean { + return this.pending.has(key); + } +} diff --git a/packages/core/tests/classifier.test.ts b/packages/core/tests/classifier.test.ts new file mode 100644 index 0000000..c1255c4 --- /dev/null +++ b/packages/core/tests/classifier.test.ts @@ -0,0 +1,76 @@ +import { describe, test, expect } from 'bun:test'; +import { isA2HMessage, isChatSDKMessage, classifyMessage } from '../src/reply-engine/classifier.js'; +import type { A2HMessage, ChatSDKMessage } from '../src/types/index.js'; + +describe('classifier', () => { + describe('isA2HMessage', () => { + test('returns true for A2H message with intent field', () => { + const msg: A2HMessage = { intent: 'AUTHORIZE' }; + expect(isA2HMessage(msg)).toBe(true); + }); + + test('returns true for all A2H intent types', () => { + const intents: A2HMessage['intent'][] = [ + 'INFORM', + 'COLLECT', + 'AUTHORIZE', + 'ESCALATE', + 'RESULT', + ]; + for (const intent of intents) { + expect(isA2HMessage({ intent })).toBe(true); + } + }); + + test('returns false for Chat SDK text message', () => { + const msg: ChatSDKMessage = { text: 'Hello' }; + expect(isA2HMessage(msg)).toBe(false); + }); + + test('returns false for Chat SDK message with blocks', () => { + const msg: ChatSDKMessage = { blocks: [{ type: 'section' }] }; + expect(isA2HMessage(msg)).toBe(false); + }); + + test('returns false for empty Chat SDK message', () => { + const msg: ChatSDKMessage = {}; + expect(isA2HMessage(msg)).toBe(false); + }); + + test('returns false for Chat SDK message with attachments', () => { + const msg: ChatSDKMessage = { attachments: [{ url: 'https://example.com/img.png' }] }; + expect(isA2HMessage(msg)).toBe(false); + }); + }); + + describe('isChatSDKMessage', () => { + test('returns true for Chat SDK message', () => { + const msg: ChatSDKMessage = { text: 'Hi' }; + expect(isChatSDKMessage(msg)).toBe(true); + }); + + test('returns false for A2H message', () => { + const msg: A2HMessage = { intent: 'COLLECT' }; + expect(isChatSDKMessage(msg)).toBe(false); + }); + }); + + describe('classifyMessage', () => { + test('classifies A2H message as "a2h"', () => { + expect(classifyMessage({ intent: 'AUTHORIZE' })).toBe('a2h'); + }); + + test('classifies Chat SDK message as "chatSdk"', () => { + expect(classifyMessage({ text: 'Hello' })).toBe('chatSdk'); + }); + + test('classifies A2H with full context as "a2h"', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + context: { action: 'get_name' }, + collect: { question: 'What is your name?' }, + }; + expect(classifyMessage(msg)).toBe('a2h'); + }); + }); +}); diff --git a/packages/core/tests/method-selector.test.ts b/packages/core/tests/method-selector.test.ts new file mode 100644 index 0000000..cda50a7 --- /dev/null +++ b/packages/core/tests/method-selector.test.ts @@ -0,0 +1,263 @@ +import { describe, test, expect } from 'bun:test'; +import { + selectReplyMethod, + selectBatchMethod, + resolveCaptureMethod, +} from '../src/reply-engine/method-selector.js'; +import type { A2HMessage, ChannelCapabilities } from '../src/types/index.js'; + +// --------------------------------------------------------------------------- +// Capability presets for concise test cases +// --------------------------------------------------------------------------- + +const fullCapabilities: ChannelCapabilities = { + supportsButtons: true, + supportsSelectMenus: true, + supportsNativeThreads: true, + supportsNativeReplies: true, + isDM: false, +}; + +const slackLike: ChannelCapabilities = { + supportsButtons: true, + supportsSelectMenus: true, + supportsNativeThreads: true, + supportsNativeReplies: false, + isDM: false, +}; + +const telegramGroup: ChannelCapabilities = { + supportsButtons: true, + supportsSelectMenus: false, + supportsNativeThreads: false, + supportsNativeReplies: true, + isDM: false, +}; + +const smsLike: ChannelCapabilities = { + supportsButtons: false, + supportsSelectMenus: false, + supportsNativeThreads: false, + supportsNativeReplies: false, + isDM: false, +}; + +const dmCapabilities: ChannelCapabilities = { + supportsButtons: false, + supportsSelectMenus: false, + supportsNativeThreads: false, + supportsNativeReplies: false, + isDM: true, +}; + +// --------------------------------------------------------------------------- +// Trust layer +// --------------------------------------------------------------------------- + +describe('selectReplyMethod — trust layer active', () => { + test('always returns method 3 regardless of intent or capabilities', () => { + const intents: A2HMessage['intent'][] = ['AUTHORIZE', 'COLLECT', 'INFORM', 'ESCALATE']; + for (const intent of intents) { + expect(selectReplyMethod({ intent }, fullCapabilities, true)).toBe(3); + } + }); +}); + +// --------------------------------------------------------------------------- +// AUTHORIZE +// --------------------------------------------------------------------------- + +describe('selectReplyMethod — AUTHORIZE', () => { + test('method 1 (inline) when channel supports buttons', () => { + expect(selectReplyMethod({ intent: 'AUTHORIZE' }, slackLike, false)).toBe(1); + }); + + test('method 1 when channel supports buttons (Telegram)', () => { + expect(selectReplyMethod({ intent: 'AUTHORIZE' }, telegramGroup, false)).toBe(1); + }); + + test('method 3 (external form) when channel has no buttons (SMS)', () => { + expect(selectReplyMethod({ intent: 'AUTHORIZE' }, smsLike, false)).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// COLLECT — closed options +// --------------------------------------------------------------------------- + +describe('selectReplyMethod — COLLECT closed options', () => { + test('method 1 when channel supports select menus (single select field)', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { fields: [{ name: 'env', type: 'select', options: ['staging', 'prod'] }] }, + }; + expect(selectReplyMethod(msg, slackLike, false)).toBe(1); + }); + + test('method 1 for checkbox field when channel has buttons', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { fields: [{ name: 'agree', type: 'checkbox', options: ['yes'] }] }, + }; + expect(selectReplyMethod(msg, telegramGroup, false)).toBe(1); + }); + + test('method 3 when channel has no select/buttons (SMS)', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { fields: [{ name: 'env', type: 'select', options: ['staging', 'prod'] }] }, + }; + expect(selectReplyMethod(msg, smsLike, false)).toBe(3); + }); + + test('method 3 for multiselect when channel has no select/buttons', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { fields: [{ name: 'tags', type: 'multiselect', options: ['a', 'b'] }] }, + }; + expect(selectReplyMethod(msg, smsLike, false)).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// COLLECT — free-text (single text field) +// --------------------------------------------------------------------------- + +describe('selectReplyMethod — COLLECT free-text single field', () => { + test('method 2 (text capture) when channel supports native threads (Slack)', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { fields: [{ name: 'reason', type: 'text' }] }, + }; + expect(selectReplyMethod(msg, slackLike, false)).toBe(2); + }); + + test('method 2 when channel supports native replies (Telegram group)', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { fields: [{ name: 'reason', type: 'text' }] }, + }; + expect(selectReplyMethod(msg, telegramGroup, false)).toBe(2); + }); + + test('method 2 when context is DM (implicit capture)', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { fields: [{ name: 'notes', type: 'textarea' }] }, + }; + expect(selectReplyMethod(msg, dmCapabilities, false)).toBe(2); + }); + + test('method 3 when channel cannot capture text natively (SMS)', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { fields: [{ name: 'notes', type: 'text' }] }, + }; + expect(selectReplyMethod(msg, smsLike, false)).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// COLLECT — free-text (question, no fields) +// --------------------------------------------------------------------------- + +describe('selectReplyMethod — COLLECT question (no fields)', () => { + test('method 2 when channel supports threads', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { question: 'What is the deployment reason?' }, + }; + expect(selectReplyMethod(msg, slackLike, false)).toBe(2); + }); + + test('method 3 when channel cannot capture (SMS)', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { question: 'What is the deployment reason?' }, + }; + expect(selectReplyMethod(msg, smsLike, false)).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// COLLECT — multiple fields +// --------------------------------------------------------------------------- + +describe('selectReplyMethod — COLLECT multiple fields', () => { + test('always method 3 regardless of channel capabilities', () => { + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { + fields: [ + { name: 'name', type: 'text' }, + { name: 'email', type: 'text' }, + ], + }, + }; + expect(selectReplyMethod(msg, fullCapabilities, false)).toBe(3); + expect(selectReplyMethod(msg, smsLike, false)).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// INFORM +// --------------------------------------------------------------------------- + +describe('selectReplyMethod — INFORM', () => { + test('returns method 1 (fire-and-forget, no blocking)', () => { + expect(selectReplyMethod({ intent: 'INFORM' }, smsLike, false)).toBe(1); + expect(selectReplyMethod({ intent: 'INFORM' }, fullCapabilities, false)).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// ESCALATE +// --------------------------------------------------------------------------- + +describe('selectReplyMethod — ESCALATE', () => { + test('returns method 3 as fallback (actual handler is in ReplyEngine)', () => { + expect(selectReplyMethod({ intent: 'ESCALATE' }, fullCapabilities, false)).toBe(3); + expect(selectReplyMethod({ intent: 'ESCALATE' }, smsLike, false)).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// Batch method +// --------------------------------------------------------------------------- + +describe('selectBatchMethod', () => { + test('always returns method 4', () => { + expect(selectBatchMethod()).toBe(4); + }); +}); + +// --------------------------------------------------------------------------- +// resolveCaptureMethod +// --------------------------------------------------------------------------- + +describe('resolveCaptureMethod', () => { + test('returns "thread" when native threads are supported', () => { + expect(resolveCaptureMethod(slackLike)).toBe('thread'); + }); + + test('returns "reply" when no native threads but native replies supported', () => { + expect(resolveCaptureMethod(telegramGroup)).toBe('reply'); + }); + + test('returns "dm" when no threads/replies but context is DM', () => { + expect(resolveCaptureMethod(dmCapabilities)).toBe('dm'); + }); + + test('returns "none" when channel cannot capture text natively', () => { + expect(resolveCaptureMethod(smsLike)).toBe('none'); + }); + + test('prefers thread over reply when both are supported', () => { + const caps: ChannelCapabilities = { + ...fullCapabilities, + supportsNativeThreads: true, + supportsNativeReplies: true, + }; + expect(resolveCaptureMethod(caps)).toBe('thread'); + }); +}); diff --git a/packages/core/tests/normalizer.test.ts b/packages/core/tests/normalizer.test.ts new file mode 100644 index 0000000..010a75b --- /dev/null +++ b/packages/core/tests/normalizer.test.ts @@ -0,0 +1,41 @@ +import { describe, test, expect } from 'bun:test'; +import { normalizeMessage } from '../src/reply-engine/normalizer.js'; +import type { ChatSDKMessage, A2HMessage } from '../src/types/index.js'; + +describe('normalizeMessage', () => { + test('wraps a single Chat SDK object in an array', () => { + const msg: ChatSDKMessage = { text: 'Hello' }; + expect(normalizeMessage(msg)).toEqual([{ text: 'Hello' }]); + }); + + test('wraps a single A2H object in an array', () => { + const msg: A2HMessage = { intent: 'AUTHORIZE' }; + expect(normalizeMessage(msg)).toEqual([{ intent: 'AUTHORIZE' }]); + }); + + test('returns the array unchanged when given an array', () => { + const msgs = [{ text: 'First' }, { intent: 'INFORM' }] as Array; + const result = normalizeMessage(msgs); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ text: 'First' }); + expect(result[1]).toEqual({ intent: 'INFORM' }); + }); + + test('returns a 1-item array when given a 1-item array', () => { + const msgs: ChatSDKMessage[] = [{ text: 'Only' }]; + const result = normalizeMessage(msgs); + expect(result).toHaveLength(1); + }); + + test('preserves item order in multi-item array', () => { + const msgs = [ + { text: 'Item 1' }, + { intent: 'AUTHORIZE' as const }, + { text: 'Item 3' }, + ] as Array; + const result = normalizeMessage(msgs); + expect(result[0]).toEqual({ text: 'Item 1' }); + expect(result[1]).toEqual({ intent: 'AUTHORIZE' }); + expect(result[2]).toEqual({ text: 'Item 3' }); + }); +}); diff --git a/packages/core/tests/reply-engine.test.ts b/packages/core/tests/reply-engine.test.ts new file mode 100644 index 0000000..ba7c953 --- /dev/null +++ b/packages/core/tests/reply-engine.test.ts @@ -0,0 +1,515 @@ +import { describe, test, expect, mock } from 'bun:test'; +import { ReplyEngine } from '../src/reply-engine/index.js'; +import { TimeoutError } from '../src/reply-engine/response-collector.js'; +import type { + A2HMessage, + A2HResponse, + ChannelAdapter, + ChannelCapabilities, + ChatSDKMessage, + ReplyEnvelope, +} from '../src/types/index.js'; + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +function makeCapabilities(overrides: Partial = {}): ChannelCapabilities { + return { + supportsButtons: true, + supportsSelectMenus: true, + supportsNativeThreads: true, + supportsNativeReplies: false, + isDM: false, + ...overrides, + }; +} + +function makeA2HResponse(intent: A2HMessage['intent'], response: unknown = true): A2HResponse { + return { intent, response, respondedAt: new Date() }; +} + +function makeAdapter( + capabilities: ChannelCapabilities, + overrides: Partial<{ + renderChatSDK: (msg: ChatSDKMessage) => Promise; + renderA2HInline: (msg: A2HMessage) => Promise; + captureResponse: (msg: A2HMessage, method: string) => Promise; + sendFormLink: (url: string, context: A2HMessage | A2HMessage[]) => Promise; + }> = {}, +): ChannelAdapter { + return { + capabilities, + renderChatSDK: overrides.renderChatSDK ?? mock(() => Promise.resolve()), + renderA2HInline: overrides.renderA2HInline ?? mock(() => Promise.resolve(makeA2HResponse('AUTHORIZE'))), + captureResponse: overrides.captureResponse ?? mock(() => Promise.resolve(makeA2HResponse('COLLECT', 'yes'))), + sendFormLink: overrides.sendFormLink ?? mock(() => Promise.resolve()), + } as ChannelAdapter; +} + +// --------------------------------------------------------------------------- +// Message parsing & normalization +// --------------------------------------------------------------------------- + +describe('ReplyEngine — message parsing', () => { + test('wraps a single Chat SDK object and renders it', async () => { + const renderChatSDK = mock(() => Promise.resolve()); + const adapter = makeAdapter(makeCapabilities(), { renderChatSDK }); + const engine = new ReplyEngine(adapter); + + const envelope: ReplyEnvelope = { message: { text: 'Hello' } }; + const result = await engine.process(envelope, 'turn_001'); + + expect(renderChatSDK).toHaveBeenCalledTimes(1); + expect(renderChatSDK).toHaveBeenCalledWith({ text: 'Hello' }); + expect(result.responses).toEqual([null]); + }); + + test('processes a 1-item array the same as a single object', async () => { + const renderChatSDK = mock(() => Promise.resolve()); + const adapter = makeAdapter(makeCapabilities(), { renderChatSDK }); + const engine = new ReplyEngine(adapter); + + const envelope: ReplyEnvelope = { message: [{ text: 'Hello' }] }; + const result = await engine.process(envelope, 'turn_002'); + + expect(renderChatSDK).toHaveBeenCalledTimes(1); + expect(result.responses).toEqual([null]); + }); +}); + +// --------------------------------------------------------------------------- +// Chat SDK path +// --------------------------------------------------------------------------- + +describe('ReplyEngine — Chat SDK path', () => { + test('delegates to renderChatSDK for Chat SDK messages', async () => { + const renderChatSDK = mock(() => Promise.resolve()); + const adapter = makeAdapter(makeCapabilities(), { renderChatSDK }); + const engine = new ReplyEngine(adapter); + + await engine.process({ message: [{ text: 'Deploy complete.' }] }, 'turn_001'); + + expect(renderChatSDK).toHaveBeenCalledWith({ text: 'Deploy complete.' }); + }); + + test('sends null response for Chat SDK messages (no blocking)', async () => { + const adapter = makeAdapter(makeCapabilities()); + const engine = new ReplyEngine(adapter); + + const result = await engine.process( + { message: [{ text: 'Notification' }, { markdown: '**bold**' }] }, + 'turn_002', + ); + + expect(result.responses).toEqual([null, null]); + }); +}); + +// --------------------------------------------------------------------------- +// A2H — INFORM (fire-and-forget) +// --------------------------------------------------------------------------- + +describe('ReplyEngine — INFORM intent', () => { + test('renders INFORM as a plain channel message and returns null response', async () => { + const renderChatSDK = mock(() => Promise.resolve()); + const adapter = makeAdapter(makeCapabilities(), { renderChatSDK }); + const engine = new ReplyEngine(adapter); + + const msg: A2HMessage = { + intent: 'INFORM', + context: { details: 'Deployment completed.' }, + }; + const result = await engine.process({ message: msg }, 'turn_003'); + + expect(renderChatSDK).toHaveBeenCalled(); + expect(result.responses).toEqual([null]); + }); +}); + +// --------------------------------------------------------------------------- +// A2H — AUTHORIZE +// --------------------------------------------------------------------------- + +describe('ReplyEngine — AUTHORIZE intent', () => { + test('method 1: delegates to renderA2HInline on capable channel', async () => { + const approvalResponse = makeA2HResponse('AUTHORIZE', true); + const renderA2HInline = mock(() => Promise.resolve(approvalResponse)); + const adapter = makeAdapter(makeCapabilities(), { renderA2HInline }); + const engine = new ReplyEngine(adapter); + + const msg: A2HMessage = { intent: 'AUTHORIZE', context: { action: 'deploy-to-prod' } }; + const result = await engine.process({ message: msg }, 'turn_004'); + + expect(renderA2HInline).toHaveBeenCalledWith(msg); + expect(result.responses[0]).toEqual(approvalResponse); + }); + + test('method 3: sends form link when channel has no buttons', async () => { + const sendFormLink = mock(() => Promise.resolve()); + const adapter = makeAdapter( + makeCapabilities({ supportsButtons: false, supportsSelectMenus: false }), + { sendFormLink }, + ); + const engine = new ReplyEngine(adapter, { timeoutMs: 100 }); + + const msg: A2HMessage = { intent: 'AUTHORIZE' }; + + // The engine blocks on method 3 — resolve it manually via the registry + const processPromise = engine.process({ message: msg }, 'turn_005'); + + // Simulate form submission + setTimeout(() => { + engine.registry.submit('turn_005', makeA2HResponse('AUTHORIZE', true)); + }, 10); + + const result = await processPromise; + expect(sendFormLink).toHaveBeenCalled(); + expect((result.responses[0] as A2HResponse).response).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// A2H — COLLECT +// --------------------------------------------------------------------------- + +describe('ReplyEngine — COLLECT intent', () => { + test('method 2: delegates to captureResponse on Slack-like channel (thread)', async () => { + const captureResponse = mock(() => Promise.resolve(makeA2HResponse('COLLECT', 'my reason'))); + const adapter = makeAdapter( + makeCapabilities({ supportsNativeThreads: true }), + { captureResponse }, + ); + const engine = new ReplyEngine(adapter); + + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { fields: [{ name: 'reason', type: 'text' }] }, + }; + const result = await engine.process({ message: msg }, 'turn_006'); + + expect(captureResponse).toHaveBeenCalledWith(msg, 'thread'); + expect((result.responses[0] as A2HResponse).response).toBe('my reason'); + }); + + test('method 1: uses inline for closed-option select field on capable channel', async () => { + const renderA2HInline = mock(() => Promise.resolve(makeA2HResponse('COLLECT', 'staging'))); + const adapter = makeAdapter(makeCapabilities(), { renderA2HInline }); + const engine = new ReplyEngine(adapter); + + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { fields: [{ name: 'env', type: 'select', options: ['staging', 'prod'] }] }, + }; + await engine.process({ message: msg }, 'turn_007'); + + expect(renderA2HInline).toHaveBeenCalledWith(msg); + }); + + test('method 3: multiple fields always use external form', async () => { + const sendFormLink = mock(() => Promise.resolve()); + const adapter = makeAdapter(makeCapabilities(), { sendFormLink }); + const engine = new ReplyEngine(adapter, { timeoutMs: 100 }); + + const msg: A2HMessage = { + intent: 'COLLECT', + collect: { + fields: [ + { name: 'name', type: 'text' }, + { name: 'email', type: 'text' }, + ], + }, + }; + + const processPromise = engine.process({ message: msg }, 'turn_008'); + setTimeout(() => { + engine.registry.submit('turn_008', makeA2HResponse('COLLECT', { name: 'Alice', email: 'alice@example.com' })); + }, 10); + + await processPromise; + expect(sendFormLink).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Mixed array (Chat SDK + A2H) +// --------------------------------------------------------------------------- + +describe('ReplyEngine — mixed array', () => { + test('sends text first, then AUTHORIZE with correct method', async () => { + const callOrder: string[] = []; + const renderChatSDK = mock(() => { + callOrder.push('renderChatSDK'); + return Promise.resolve(); + }); + const approvalResponse = makeA2HResponse('AUTHORIZE', true); + const renderA2HInline = mock(() => { + callOrder.push('renderA2HInline'); + return Promise.resolve(approvalResponse); + }); + const adapter = makeAdapter(makeCapabilities(), { renderChatSDK, renderA2HInline }); + const engine = new ReplyEngine(adapter); + + const envelope: ReplyEnvelope = { + message: [ + { text: 'Tests passed. Ready for production.' }, + { intent: 'AUTHORIZE', context: { action: 'deploy-to-production' } }, + ], + }; + const result = await engine.process(envelope, 'turn_009'); + + expect(callOrder).toEqual(['renderChatSDK', 'renderA2HInline']); + expect(result.responses[0]).toBeNull(); + expect(result.responses[1]).toEqual(approvalResponse); + }); + + test('returns responses array in same order as items', async () => { + const informMsg: A2HMessage = { + intent: 'INFORM', + context: { details: 'System update' }, + }; + const textMsg: ChatSDKMessage = { text: 'Hello' }; + const authMsg: A2HMessage = { intent: 'AUTHORIZE' }; + const authResponse = makeA2HResponse('AUTHORIZE', true); + + const renderA2HInline = mock(() => Promise.resolve(authResponse)); + const adapter = makeAdapter(makeCapabilities(), { renderA2HInline }); + const engine = new ReplyEngine(adapter); + + const result = await engine.process( + { message: [textMsg, informMsg, authMsg] }, + 'turn_010', + ); + + expect(result.responses).toHaveLength(3); + expect(result.responses[0]).toBeNull(); // Chat SDK text + expect(result.responses[1]).toBeNull(); // INFORM (fire-and-forget) + expect(result.responses[2]).toEqual(authResponse); // AUTHORIZE + }); +}); + +// --------------------------------------------------------------------------- +// Multiple A2H intents → method 4 (batch form) +// --------------------------------------------------------------------------- + +describe('ReplyEngine — multiple A2H intents (method 4)', () => { + test('batches multiple A2H intents to method 4 and awaits form submission', async () => { + const sendFormLink = mock(() => Promise.resolve()); + const adapter = makeAdapter(makeCapabilities(), { sendFormLink }); + const engine = new ReplyEngine(adapter, { timeoutMs: 500 }); + + const msgs: A2HMessage[] = [ + { intent: 'AUTHORIZE', context: { action: 'deploy' } }, + { intent: 'COLLECT', collect: { question: 'Reason?' } }, + ]; + + const processPromise = engine.process({ message: msgs }, 'turn_011'); + + // Simulate form submission for each sub-key + setTimeout(() => { + engine.registry.submit('turn_011_batch_0', makeA2HResponse('AUTHORIZE', true)); + engine.registry.submit('turn_011_batch_1', makeA2HResponse('COLLECT', 'deploy new feature')); + }, 20); + + const result = await processPromise; + + expect(sendFormLink).toHaveBeenCalledTimes(1); + // The form URL includes the batch key + const formUrl = (sendFormLink.mock.calls[0] as [string, unknown])[0] as string; + expect(formUrl).toContain('turn_011_batch'); + expect((result.responses[0] as A2HResponse).response).toBe(true); + expect((result.responses[1] as A2HResponse).response).toBe('deploy new feature'); + }); + + test('sends Chat SDK items before the batch form', async () => { + const callOrder: string[] = []; + const renderChatSDK = mock(() => { + callOrder.push('chat'); + return Promise.resolve(); + }); + const sendFormLink = mock(() => { + callOrder.push('form'); + return Promise.resolve(); + }); + const adapter = makeAdapter(makeCapabilities(), { renderChatSDK, sendFormLink }); + const engine = new ReplyEngine(adapter, { timeoutMs: 200 }); + + const processPromise = engine.process( + { + message: [ + { text: 'Preamble' }, + { intent: 'AUTHORIZE' }, + { intent: 'COLLECT', collect: { question: 'Why?' } }, + ], + }, + 'turn_012', + ); + + setTimeout(() => { + engine.registry.submit('turn_012_batch_0', makeA2HResponse('AUTHORIZE', true)); + engine.registry.submit('turn_012_batch_1', makeA2HResponse('COLLECT', 'because')); + }, 20); + + await processPromise; + expect(callOrder).toEqual(['chat', 'form']); + }); +}); + +// --------------------------------------------------------------------------- +// Trust layer +// --------------------------------------------------------------------------- + +describe('ReplyEngine — trust layer', () => { + test('forces method 3 for AUTHORIZE even on fully capable channel', async () => { + const sendFormLink = mock(() => Promise.resolve()); + const renderA2HInline = mock(() => Promise.resolve(makeA2HResponse('AUTHORIZE'))); + const adapter = makeAdapter(makeCapabilities(), { sendFormLink, renderA2HInline }); + const engine = new ReplyEngine(adapter, { trustLayerActive: true, timeoutMs: 100 }); + + const processPromise = engine.process({ message: { intent: 'AUTHORIZE' } }, 'turn_013'); + setTimeout(() => { + engine.registry.submit('turn_013', makeA2HResponse('AUTHORIZE', true)); + }, 10); + + await processPromise; + expect(renderA2HInline).not.toHaveBeenCalled(); + expect(sendFormLink).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Timeout handling +// --------------------------------------------------------------------------- + +describe('ReplyEngine — timeout', () => { + test('throws TimeoutError when blocking intent does not respond in time', async () => { + const sendFormLink = mock(() => Promise.resolve()); + const adapter = makeAdapter( + makeCapabilities({ supportsButtons: false }), + { sendFormLink }, + ); + const engine = new ReplyEngine(adapter, { timeoutMs: 50 }); + + await expect( + engine.process({ message: { intent: 'AUTHORIZE' } }, 'turn_014'), + ).rejects.toThrow(TimeoutError); + }); + + test('TimeoutError contains the intent and turnId', async () => { + const sendFormLink = mock(() => Promise.resolve()); + const adapter = makeAdapter( + makeCapabilities({ supportsButtons: false }), + { sendFormLink }, + ); + const engine = new ReplyEngine(adapter, { timeoutMs: 50 }); + + try { + await engine.process({ message: { intent: 'COLLECT' } }, 'turn_015'); + throw new Error('Expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(TimeoutError); + const te = err as TimeoutError; + expect(te.intent).toBe('COLLECT'); + expect(te.turnId).toBe('turn_015'); + expect(te.timeoutMs).toBe(50); + } + }); +}); + +// --------------------------------------------------------------------------- +// Escalation handler +// --------------------------------------------------------------------------- + +describe('ReplyEngine — ESCALATE intent', () => { + test('calls escalation handler when configured', async () => { + const escalationResponse = makeA2HResponse('ESCALATE', { operatorId: 'op_001' }); + const escalationHandler = { + handle: mock(() => Promise.resolve(escalationResponse)), + }; + const adapter = makeAdapter(makeCapabilities()); + const engine = new ReplyEngine(adapter, { escalationHandler }); + + const msg: A2HMessage = { intent: 'ESCALATE', context: { details: 'Critical error' } }; + const result = await engine.process({ message: msg }, 'turn_016'); + + expect(escalationHandler.handle).toHaveBeenCalledWith(msg); + expect(result.responses[0]).toEqual(escalationResponse); + }); + + test('falls back to method 3 (form link) when no escalation handler configured', async () => { + const sendFormLink = mock(() => Promise.resolve()); + const adapter = makeAdapter(makeCapabilities(), { sendFormLink }); + const engine = new ReplyEngine(adapter, { timeoutMs: 100 }); + + const processPromise = engine.process({ message: { intent: 'ESCALATE' } }, 'turn_017'); + setTimeout(() => { + engine.registry.submit('turn_017', makeA2HResponse('ESCALATE', { operator: 'alice' })); + }, 10); + + const result = await processPromise; + expect(sendFormLink).toHaveBeenCalled(); + expect((result.responses[0] as A2HResponse).intent).toBe('ESCALATE'); + }); +}); + +// --------------------------------------------------------------------------- +// ResponseRegistry +// --------------------------------------------------------------------------- + +describe('ResponseRegistry (via engine.registry)', () => { + test('submit resolves the pending promise', async () => { + const sendFormLink = mock(() => Promise.resolve()); + const adapter = makeAdapter( + makeCapabilities({ supportsButtons: false }), + { sendFormLink }, + ); + const engine = new ReplyEngine(adapter, { timeoutMs: 1000 }); + + const processPromise = engine.process({ message: { intent: 'AUTHORIZE' } }, 'reg_001'); + + const submitted = engine.registry.submit('reg_001', makeA2HResponse('AUTHORIZE', false)); + expect(submitted).toBe(true); + + const result = await processPromise; + expect((result.responses[0] as A2HResponse).response).toBe(false); + }); + + test('cancel rejects the pending promise', async () => { + const sendFormLink = mock(() => Promise.resolve()); + const adapter = makeAdapter( + makeCapabilities({ supportsButtons: false }), + { sendFormLink }, + ); + const engine = new ReplyEngine(adapter, { timeoutMs: 1000 }); + + const processPromise = engine.process({ message: { intent: 'AUTHORIZE' } }, 'reg_002'); + + engine.registry.cancel('reg_002', new Error('Form expired')); + + await expect(processPromise).rejects.toThrow('Form expired'); + }); + + test('submit returns false for unknown key', () => { + const engine = new ReplyEngine(makeAdapter(makeCapabilities())); + expect(engine.registry.submit('unknown', makeA2HResponse('AUTHORIZE'))).toBe(false); + }); + + test('form URL uses configured formBaseUrl', async () => { + const sendFormLink = mock(() => Promise.resolve()); + const adapter = makeAdapter( + makeCapabilities({ supportsButtons: false }), + { sendFormLink }, + ); + const engine = new ReplyEngine(adapter, { + formBaseUrl: 'https://my-instance.example.com/form', + timeoutMs: 100, + }); + + const processPromise = engine.process({ message: { intent: 'AUTHORIZE' } }, 'turn_018'); + engine.registry.submit('turn_018', makeA2HResponse('AUTHORIZE', true)); + + await processPromise; + const url = (sendFormLink.mock.calls[0] as [string, unknown])[0] as string; + expect(url).toBe('https://my-instance.example.com/form/turn_018'); + }); +});