From 3ffa2d3137da7ce9bb1634a9d9b118b65ed2a1e7 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:16:07 +0000 Subject: [PATCH] feat: implement Core Reply Engine (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Reply Engine component in packages/core — the component that processes recipient inbound envelopes, classifies each message item (Chat SDK vs A2H via duck typing), selects the appropriate reply method via the automatic decision tree, and orchestrates blocking response collection. Key components: - types/index.ts: full data model (ChatSDKMessage, A2HMessage, ChannelAdapter, ReplyEngineConfig, and all supporting types) - reply-engine/normalizer.ts: normalize single object → 1-item array - reply-engine/classifier.ts: duck-typing classification (intent field = A2H) - reply-engine/method-selector.ts: decision tree for methods 1–4 with capture hierarchy for method 2 - reply-engine/response-collector.ts: TimeoutError, withTimeout(), and ResponseRegistry for pending form submissions (methods 3 & 4) - reply-engine/index.ts: ReplyEngine class orchestrating all of the above Unit tests cover: classification, method selection across all intent/capability combinations, normalizer, mixed arrays, method 4 batching, trust layer, timeout handling, escalation handler, and ResponseRegistry. Co-authored-by: claude[bot] --- package.json | 7 + packages/core/package.json | 16 + packages/core/src/index.ts | 4 + packages/core/src/reply-engine/classifier.ts | 27 + packages/core/src/reply-engine/index.ts | 291 ++++++++++ .../core/src/reply-engine/method-selector.ts | 123 +++++ packages/core/src/reply-engine/normalizer.ts | 15 + .../src/reply-engine/response-collector.ts | 116 ++++ packages/core/src/types/index.ts | 220 ++++++++ packages/core/tests/classifier.test.ts | 76 +++ packages/core/tests/method-selector.test.ts | 263 +++++++++ packages/core/tests/normalizer.test.ts | 41 ++ packages/core/tests/reply-engine.test.ts | 515 ++++++++++++++++++ packages/core/tsconfig.json | 12 + 14 files changed, 1726 insertions(+) create mode 100644 package.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/reply-engine/classifier.ts create mode 100644 packages/core/src/reply-engine/index.ts create mode 100644 packages/core/src/reply-engine/method-selector.ts create mode 100644 packages/core/src/reply-engine/normalizer.ts create mode 100644 packages/core/src/reply-engine/response-collector.ts create mode 100644 packages/core/src/types/index.ts create mode 100644 packages/core/tests/classifier.test.ts create mode 100644 packages/core/tests/method-selector.test.ts create mode 100644 packages/core/tests/normalizer.test.ts create mode 100644 packages/core/tests/reply-engine.test.ts create mode 100644 packages/core/tsconfig.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..957bf25 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "openthreads", + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..6591f13 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,16 @@ +{ + "name": "@openthreads/core", + "version": "0.0.1", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.4.0" + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..3c397a8 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,4 @@ +// Core package public API + +export * from './types/index.js'; +export * from './reply-engine/index.js'; 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/src/types/index.ts b/packages/core/src/types/index.ts new file mode 100644 index 0000000..35e2871 --- /dev/null +++ b/packages/core/src/types/index.ts @@ -0,0 +1,220 @@ +/** + * Core types and interfaces for OpenThreads. + * + * The message envelope accepts both Vercel Chat SDK messages and A2H protocol + * intents, identified by duck typing: presence of `intent` field = A2H. + */ + +// --------------------------------------------------------------------------- +// Chat SDK Types (Vercel Chat SDK format) +// --------------------------------------------------------------------------- + +export interface Attachment { + url?: string; + contentType?: string; + name?: string; +} + +export interface ChatSDKMessage { + text?: string; + markdown?: string; + attachments?: Attachment[]; + blocks?: unknown[]; + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// A2H Protocol Types +// --------------------------------------------------------------------------- + +/** The five atomic A2H intents from Layer 1 of the A2H spec. */ +export type A2HIntent = 'INFORM' | 'COLLECT' | 'AUTHORIZE' | 'ESCALATE' | 'RESULT'; + +/** A field within a COLLECT intent. */ +export interface CollectField { + name: string; + /** Closed option types can be rendered as buttons/selects in the channel. */ + type: 'text' | 'textarea' | 'select' | 'multiselect' | 'checkbox' | 'date' | 'number'; + label?: string; + /** Options for select/multiselect/checkbox types — makes this a "closed option" field. */ + options?: string[]; + required?: boolean; +} + +/** A2H message following the A2H protocol spec (presence of `intent` = A2H). */ +export interface A2HMessage { + intent: A2HIntent; + context?: { + action?: string; + details?: string; + justification?: string; + evidence?: unknown; + [key: string]: unknown; + }; + /** COLLECT-specific: defines the fields to collect and/or a question prompt. */ + collect?: { + question?: string; + fields?: CollectField[]; + }; + /** Trace ID for auditing and idempotency. */ + traceId?: string; + nonce?: string; +} + +/** Discriminated union of all valid message item types in the envelope. */ +export type MessageItem = ChatSDKMessage | A2HMessage; + +// --------------------------------------------------------------------------- +// Channel Capabilities +// --------------------------------------------------------------------------- + +/** + * Describes what the destination channel can render natively. + * The Reply Engine uses this to select the best reply method. + */ +export interface ChannelCapabilities { + /** Channel supports interactive button components (Slack, Discord, Telegram). */ + supportsButtons: boolean; + /** Channel supports select/dropdown menus (Slack block kit selects, etc.). */ + supportsSelectMenus: boolean; + /** Channel supports native message threads (Slack threads, Discord forum channels). */ + supportsNativeThreads: boolean; + /** Channel supports replying to a specific message (Telegram reply, WhatsApp quote). */ + supportsNativeReplies: boolean; + /** The current context is a Direct Message (implies implicit capture for method 2). */ + isDM: boolean; +} + +// --------------------------------------------------------------------------- +// Reply Method +// --------------------------------------------------------------------------- + +/** + * The four reply methods the Reply Engine can use for A2H intents. + * + * 1 — Inline in channel (buttons/actions) + * 2 — Text capture (thread, reply, or DM) + * 3 — External form (temporary link) + * 4 — Batch form (multiple intents on a single page) + */ +export type ReplyMethod = 1 | 2 | 3 | 4; + +/** How method 2 captures the response from the human. */ +export type CaptureMethod = 'thread' | 'reply' | 'dm' | 'none'; + +// --------------------------------------------------------------------------- +// A2H Response +// --------------------------------------------------------------------------- + +/** The response collected from the human for a single A2H intent. */ +export interface A2HResponse { + intent: A2HIntent; + /** The human's response payload (approval boolean, text string, selected option, etc.). */ + response: unknown; + respondedAt?: Date; +} + +// --------------------------------------------------------------------------- +// Channel Adapter Interface +// --------------------------------------------------------------------------- + +/** + * The channel adapter interface that the Reply Engine delegates to. + * + * Concrete implementations live in packages/channels/ or are provided by the + * Vercel Chat SDK adapters in packages/core. + */ +export interface ChannelAdapter { + /** Capabilities of the underlying channel. */ + readonly capabilities: ChannelCapabilities; + + /** + * Render a Chat SDK message to the channel (fire-and-forget). + * Used for text, markdown, blocks, etc. + */ + renderChatSDK(message: ChatSDKMessage): Promise; + + /** + * Render an A2H intent inline using native channel primitives (method 1). + * Returns the human's response once they interact with the buttons/select. + */ + renderA2HInline(message: A2HMessage): Promise; + + /** + * Capture a free-text response via the channel's native affordances (method 2). + * The capture method depends on channel capabilities: + * 'thread' — capture any sender message in the native thread + * 'reply' — capture a direct reply to the COLLECT message + * 'dm' — capture next message in the DM (implicit context) + * 'none' — channel doesn't support capture (should not be passed here) + */ + captureResponse(message: A2HMessage, captureMethod: Exclude): Promise; + + /** + * Send a form link to the channel (methods 3 and 4). + * The link points to the auto-generated A2H form page. + */ + sendFormLink(formUrl: string, context: A2HMessage | A2HMessage[]): Promise; +} + +// --------------------------------------------------------------------------- +// Escalation Handler +// --------------------------------------------------------------------------- + +/** + * Optional hook for handling ESCALATE intents. + * When provided, the Reply Engine calls this instead of falling back to method 3. + */ +export interface EscalationHandler { + handle(message: A2HMessage): Promise; +} + +// --------------------------------------------------------------------------- +// Reply Engine Config & Result +// --------------------------------------------------------------------------- + +export interface ReplyEngineConfig { + /** + * Timeout in milliseconds for blocking A2H intents (COLLECT, AUTHORIZE, ESCALATE). + * @default 300000 (5 minutes) + */ + timeoutMs?: number; + + /** + * When true, all A2H intents are routed to method 3 (external form) because + * strong authentication (WebAuthn/passkeys) is only supported there. + * @default false + */ + trustLayerActive?: boolean; + + /** + * Base URL for auto-generated A2H form pages. + * Form URLs are constructed as `${formBaseUrl}/${turnId}`. + * @default "https://openthreads.host/form" + */ + formBaseUrl?: string; + + /** + * Optional handler for ESCALATE intents. + * Falls back to method 3 (external form) when not provided. + */ + escalationHandler?: EscalationHandler; +} + +/** The result returned by the Reply Engine after processing an envelope. */ +export interface ReplyEngineResult { + /** + * Responses collected for each item in the message array, in the same order. + * null for Chat SDK messages and INFORM intents (non-blocking / fire-and-forget). + */ + responses: (A2HResponse | null)[]; +} + +/** The inbound envelope POSTed to the recipient inbound endpoint. */ +export interface ReplyEnvelope { + /** + * A single message object or an array. When a single object, it is normalized + * to a 1-item array. Each item is either a Chat SDK message or an A2H intent. + */ + message: MessageItem | MessageItem[]; +} 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'); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..f9b93aa --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["bun-types"] + }, + "include": ["src/**/*", "tests/**/*"] +}