diff --git a/.changeset/ts-server-choices-interaction.md b/.changeset/ts-server-choices-interaction.md new file mode 100644 index 00000000..c97b9263 --- /dev/null +++ b/.changeset/ts-server-choices-interaction.md @@ -0,0 +1,9 @@ +--- +'@smooai/smooth-operator-server': minor +--- + +Port the Rich Interactions runtime + the `choices` kind (AskUserQuestion) to the TypeScript server, mirroring the Rust reference. + +A kind-agnostic framework (`interaction.ts`): the `InteractionKind` seam, an `InteractionRegistry` of hosted kinds, a session-keyed `InteractionParkRegistry` (the interaction analog of the write-confirmation registry), and the per-kind `request_` raise tool + generic `submit_interaction` fallback tool. An agent raise parks the turn when the session declared the kind's render capability (`supports` at `create_conversation_session`) — the raise tool awaits inside `execute`, the server emits `interaction_required { interactionId, kind, spec, reason }`, and a `submit_interaction` action resolves it. On a text-only channel the same raise degrades to the kind's conversational-fallback directive. Both paths run the kind's server-side validator and resume with the same canonical payload. + +The `choices` kind (`choices.ts`, mirroring `choices.rs`): `request_choices` with 1–4 questions (each a short ≤12-char header, 2–4 options, optional `multiSelect`) and a `reason`; the `validate_choices` rules (every question answered, each label offered, single-select takes exactly one pick label-XOR-other, multi-select one or more, blank `other` dropped, one-pass errors); capability id `choice_chips`. Invalid submits emit a retryable `interaction_invalid` event and keep the turn parked (never a terminal error). The session's declared `supports` is now persisted (in-memory + Postgres stores) and gates the rich-vs-fallback decision per kind. The server hosts the `choices` kind by default. Validated against the shared `spec/conformance/fixtures.json` `choices` fixtures. diff --git a/typescript/server/src/choices.ts b/typescript/server/src/choices.ts new file mode 100644 index 00000000..0204456f --- /dev/null +++ b/typescript/server/src/choices.ts @@ -0,0 +1,340 @@ +/** + * Choices — a structured multiple-choice ask (modeled on Claude Code's + * `AskUserQuestion`): a **Rich Interaction kind** (see {@link ./interaction.js}). + * + * The agent asks 1–4 short questions, each with 2–4 labeled options; the turn + * parks until the visitor picks. Every question also carries an implicit + * free-text **"Other"** escape hatch, so the visitor can answer outside the + * enumerated options (exactly as `AskUserQuestion` always offers "Other"). + * + * - On a channel that declared the `choice_chips` capability, the agent's + * `request_choices` tool parks the turn and the server emits + * `interaction_required { kind: "choices" }`; the client's chip/menu card + * resumes with a `submit_interaction` action. + * - On a **text-only** channel the same raise degrades to a conversational + * directive that enumerates the questions + options and the model submits the + * picks through the generic `submit_interaction` *tool*. + * + * Both paths validate through {@link validateChoices} — one implementation, one + * behavior — and resume the turn with the same structured payload. The + * TypeScript port of the Rust reference `smooth-operator/src/choices.rs`. + */ +import type { InteractionKind, InteractionRequest, InteractionValidation } from './interaction.js'; + +/** Max length of a question's short `header` label (chip/tab caption). */ +export const HEADER_MAX_CHARS = 12; + +/** One selectable option in a question. */ +export interface ChoiceOption { + /** The option's label — the value the visitor submits. */ + label: string; + /** A short human-readable gloss shown under/next to the label. */ + description: string; +} + +/** One question in a `choices` raise. */ +export interface ChoiceQuestion { + /** The question prompt shown to the visitor. */ + question: string; + /** A short label (≤{@link HEADER_MAX_CHARS} chars) — the answer key and the chip/tab caption. Unique within a raise. */ + header: string; + /** The 2–4 enumerated options. An implicit free-text "Other" is always available in addition. */ + options: ChoiceOption[]; + /** Whether the visitor may pick more than one option (default `false`). */ + multiSelect: boolean; +} + +/** The visitor's answer to one question, submitted via `submit_interaction`. */ +export interface ChoiceAnswer { + /** Which question this answers — matches the spec question's `header`. */ + header: string; + /** The selected option label(s). Empty when the visitor only used the free-text "Other". */ + options: string[]; + /** The free-text "Other" answer, when the visitor answered outside the enumerated options. Blank ⇒ omitted. */ + other?: string; +} + +/** Validated, normalized choice answers — the structured payload the parked turn resumes with. */ +export interface ChoiceValues { + /** One entry per answered question (in submission order). */ + answers: ChoiceAnswer[]; +} + +/** A single per-question validation failure. `field` is the question's `header`. */ +export interface ChoiceFieldError { + field: string; + message: string; +} + +/** Total picks the visitor made (selected labels + one for a non-blank "Other"). */ +function selectionCount(answer: ChoiceAnswer): number { + return answer.options.length + (answer.other !== undefined ? 1 : 0); +} + +/** + * Validate submitted `values` against the raised `questions`, returning the + * normalized {@link ChoiceValues} or the full list of per-question errors. + * + * Rules (mirrors `choices.rs`): + * - **every** question must be answered (a selection or a non-blank "Other"); + * - each selected label must be one of that question's option labels; + * - single-select: exactly one pick (one label XOR "Other"); multi-select: one or more picks; + * - a blank/whitespace "Other" is treated as absent; labels are trimmed. + * + * When `questions` is empty (a prior-turn fallback raise whose spec is gone), + * validation degrades to **format-only**: labels can't be checked for + * membership, so any answer with at least one pick is accepted as-is. + * + * Returns every failed question (not just the first) so a card can annotate all + * of them in one round-trip. + */ +export function validateChoices(questions: ChoiceQuestion[], values: ChoiceValues): { ok: true; values: ChoiceValues } | { ok: false; errors: ChoiceFieldError[] } { + // Normalize the raw answers first (trim labels + "Other", drop blanks). + const normalized: ChoiceAnswer[] = values.answers.map((a) => { + const other = a.other?.trim(); + const answer: ChoiceAnswer = { + header: a.header.trim(), + options: a.options.map((o) => o.trim()).filter((o) => o.length > 0), + }; + if (other && other.length > 0) answer.other = other; + return answer; + }); + + // Format-only path: no spec to check membership / required-ness against. + if (questions.length === 0) { + const errors: ChoiceFieldError[] = []; + for (const answer of normalized) { + if (selectionCount(answer) === 0) { + errors.push({ field: answer.header, message: "select an option or provide an 'other' answer" }); + } + } + if (normalized.length === 0) { + errors.push({ field: 'answers', message: 'provide an answer for each question, or declined=true' }); + } + return errors.length === 0 ? { ok: true, values: { answers: normalized } } : { ok: false, errors }; + } + + const errors: ChoiceFieldError[] = []; + const out: ChoiceAnswer[] = []; + + for (const question of questions) { + const answer = normalized.find((a) => a.header === question.header); + if (!answer) { + errors.push({ field: question.header, message: 'this question must be answered' }); + continue; + } + + // Every selected label must be one of the enumerated options. + let badLabel = false; + for (const label of answer.options) { + if (!question.options.some((o) => o.label === label)) { + badLabel = true; + errors.push({ field: question.header, message: `'${label}' is not one of the offered options` }); + } + } + + const count = selectionCount(answer); + if (count === 0) { + errors.push({ field: question.header, message: "select an option or provide an 'other' answer" }); + } else if (!question.multiSelect && count > 1) { + errors.push({ field: question.header, message: 'this question takes a single answer' }); + } + + if (!badLabel) out.push(answer); + } + + return errors.length === 0 ? { ok: true, values: { answers: out } } : { ok: false, errors }; +} + +/** + * Parse the raise tool's `questions` argument into validated {@link ChoiceQuestion}s. + * + * Enforces the LLM-facing contract so the model produces usable cards: 1–4 + * questions, each with a non-empty prompt, a non-empty header ≤12 chars (unique + * within the raise), and 2–4 options with non-empty labels. + */ +export function parseQuestions(raw: unknown): ChoiceQuestion[] { + if (!Array.isArray(raw)) throw new Error("'questions' must be an array"); + if (raw.length < 1 || raw.length > 4) throw new Error("'questions' must contain between 1 and 4 questions"); + + const questions: ChoiceQuestion[] = []; + const seenHeaders = new Set(); + for (const item of raw) { + if (typeof item !== 'object' || item === null || Array.isArray(item)) throw new Error('each question must be an object'); + const obj = item as Record; + + const question = typeof obj.question === 'string' ? obj.question.trim() : ''; + if (!question) throw new Error("each question needs a non-empty 'question'"); + + const header = typeof obj.header === 'string' ? obj.header.trim() : ''; + if (!header) throw new Error("each question needs a non-empty 'header'"); + if ([...header].length > HEADER_MAX_CHARS) throw new Error(`header '${header}' is too long (max ${HEADER_MAX_CHARS} characters)`); + if (seenHeaders.has(header)) throw new Error(`duplicate question header '${header}'`); + seenHeaders.add(header); + + const rawOptions = obj.options; + if (!Array.isArray(rawOptions)) throw new Error(`question '${header}' needs an 'options' array`); + if (rawOptions.length < 2 || rawOptions.length > 4) throw new Error(`question '${header}' must offer between 2 and 4 options`); + + const options: ChoiceOption[] = []; + for (const opt of rawOptions) { + // Accept the object form `{ label, description? }` and the shorthand bare string. + let option: ChoiceOption; + if (typeof opt === 'string') { + option = { label: opt.trim(), description: '' }; + } else if (typeof opt === 'object' && opt !== null && !Array.isArray(opt)) { + const o = opt as Record; + option = { + label: typeof o.label === 'string' ? o.label.trim() : '', + description: typeof o.description === 'string' ? o.description.trim() : '', + }; + } else { + throw new Error(`invalid option entry in '${header}'`); + } + if (!option.label) throw new Error(`an option in '${header}' has an empty label`); + options.push(option); + } + + questions.push({ question, header, options, multiSelect: obj.multiSelect === true }); + } + return questions; +} + +/** Coerce the submitted `values` into {@link ChoiceValues}, or throw a shape error. */ +function parseValues(values: unknown): ChoiceValues { + if (typeof values !== 'object' || values === null || Array.isArray(values)) throw new Error('values must be an object'); + const answersRaw = (values as Record).answers; + if (!Array.isArray(answersRaw)) throw new Error("values.answers must be an array"); + const answers: ChoiceAnswer[] = answersRaw.map((a) => { + if (typeof a !== 'object' || a === null || Array.isArray(a)) throw new Error('each answer must be an object'); + const o = a as Record; + const answer: ChoiceAnswer = { + header: typeof o.header === 'string' ? o.header : '', + options: Array.isArray(o.options) ? o.options.filter((x): x is string => typeof x === 'string') : [], + }; + if (typeof o.other === 'string') answer.other = o.other; + return answer; + }); + return { answers }; +} + +/** + * The `choices` Rich Interaction kind — a structured multiple-choice ask modeled + * on `AskUserQuestion` (see the module docs and `spec/interactions/choices.schema.json`). + */ +export class ChoicesKind implements InteractionKind { + readonly kind = 'choices'; + readonly capability = 'choice_chips'; + + toolSchema(): { name: string; description: string; parameters: Record } { + return { + name: 'request_choices', + description: + 'Ask the visitor a structured multiple-choice question (1–4 questions, each with 2–4 labeled options) ' + + 'and wait for their pick. On channels that can render chips/menus the visitor taps an option; on text ' + + 'channels you will be told to enumerate the options and accept a natural-language answer. An implicit ' + + 'free-text "Other" is always available, so use this whenever the answer is likely (but not certainly) ' + + 'one of a small set — never free-form the menu yourself.', + parameters: { + type: 'object', + properties: { + questions: { + type: 'array', + minItems: 1, + maxItems: 4, + description: 'The questions to ask, in order (1–4).', + items: { + type: 'object', + properties: { + question: { type: 'string', description: 'The question prompt shown to the visitor.' }, + header: { type: 'string', maxLength: HEADER_MAX_CHARS, description: 'A short label (≤12 chars), unique within the raise. Used as the answer key and the chip/tab caption.' }, + options: { + type: 'array', + minItems: 2, + maxItems: 4, + description: "The 2–4 options to offer. A free-text 'Other' is always available in addition.", + items: { + type: 'object', + properties: { + label: { type: 'string', description: 'The option label (the value submitted).' }, + description: { type: 'string', description: 'A short gloss for the option.' }, + }, + required: ['label'], + }, + }, + multiSelect: { type: 'boolean', description: 'Allow selecting more than one option (default false).' }, + }, + required: ['question', 'header', 'options'], + }, + }, + reason: { + type: 'string', + description: 'Why you\'re asking, phrased for the visitor (e.g. "to route you to the right team").', + }, + }, + required: ['questions', 'reason'], + }, + }; + } + + parseRequest(args: Record): InteractionRequest { + const questions = parseQuestions(args.questions ?? null); + const reason = typeof args.reason === 'string' && args.reason.trim().length > 0 ? args.reason.trim() : 'to help you better'; + return { kind: this.kind, spec: { questions }, reason }; + } + + validate(spec: unknown, values: unknown): InteractionValidation { + const questions = specQuestions(spec); + let parsed: ChoiceValues; + try { + parsed = parseValues(values); + } catch (err) { + return { ok: false, errors: [{ field: 'values', message: `invalid values shape: ${err instanceof Error ? err.message : String(err)}` }] }; + } + const result = validateChoices(questions, parsed); + return result.ok ? { ok: true, values: result.values } : { ok: false, errors: result.errors }; + } + + fallbackDirective(spec: unknown, reason: string): string { + const questions = specQuestions(spec); + const enumerated = questions + .map((q) => { + const opts = q.options.map((o) => o.label).join(', '); + return `- [${q.header}] ${q.question} Options: ${opts}${q.multiSelect ? ' (choose one or more)' : ''}.`; + }) + .join('\n'); + return ( + "This visitor's channel cannot display choice chips. Ask the following question(s) conversationally, " + + `naturally weaving in the reason (${reason}), and read out each option so the visitor can pick:\n${enumerated}\n` + + "The visitor may also answer with something not listed (that's fine — capture it as their 'other' answer). " + + 'When you have their pick(s), call the `submit_interaction` tool with kind "choices" and `values.answers` — ' + + 'one entry per question `{ header, options: [chosen label(s)], other?: "their free-text answer" }`. It ' + + "validates each answer and will tell you if a pick isn't offered so you can re-ask. If the visitor declines " + + 'to choose, call `submit_interaction` with declined=true and continue helping them.' + ); + } +} + +/** Read the `questions` out of a raise spec (best-effort; a malformed/absent spec ⇒ format-only []). */ +function specQuestions(spec: unknown): ChoiceQuestion[] { + if (typeof spec !== 'object' || spec === null) return []; + const raw = (spec as Record).questions; + if (!Array.isArray(raw)) return []; + const questions: ChoiceQuestion[] = []; + for (const q of raw) { + if (typeof q !== 'object' || q === null) continue; + const o = q as Record; + const options = Array.isArray(o.options) + ? o.options + .filter((op): op is Record => typeof op === 'object' && op !== null) + .map((op) => ({ label: typeof op.label === 'string' ? op.label : '', description: typeof op.description === 'string' ? op.description : '' })) + : []; + questions.push({ + question: typeof o.question === 'string' ? o.question : '', + header: typeof o.header === 'string' ? o.header : '', + options, + multiSelect: o.multiSelect === true, + }); + } + return questions; +} diff --git a/typescript/server/src/frameDispatcher.ts b/typescript/server/src/frameDispatcher.ts index 1410388e..0c367b8e 100644 --- a/typescript/server/src/frameDispatcher.ts +++ b/typescript/server/src/frameDispatcher.ts @@ -20,6 +20,7 @@ import { resolveSection, type SkillResolver } from './skills.js'; import { gateTools, type SessionAuthenticator } from './toolGating.js'; import { ANONYMOUS_ACCESS, type AccessContext } from './auth.js'; import { ConfirmationRegistry } from './confirmation.js'; +import { InteractionParkRegistry, InteractionRegistry, requestInteractionTool, submitInteractionTool, type InteractionOutcome, type RaisedSpecs } from './interaction.js'; import { buildExtensionHost } from './extensions.js'; import { availableChannels, isContactEmpty, type OtpContact, type OtpRefusal, type OtpService } from './otp.js'; import type { ModelCeilingResolver } from './modelCeiling.js'; @@ -98,6 +99,13 @@ export interface FrameDispatcherOptions { * connection). Created on demand if not supplied. */ confirmations?: ConfirmationRegistry; + /** + * The Rich Interactions the server hosts (raise tools registered per turn, gated + * per-kind by the session's declared `supports`). Defaults to an empty registry → + * no interaction tools, behaviour unchanged. The server passes the reference + * catalog (the `choices` kind). + */ + interactions?: InteractionRegistry; /** Model id for turns (default {@link DEFAULT_MODEL}); forwarded to the {@link TurnRunner}. */ model?: string; /** Best-effort per-model output-ceiling resolver; forwarded to the {@link TurnRunner} (EPIC th-1cc9fa). */ @@ -131,6 +139,10 @@ export class FrameDispatcher { private readonly toolHooks: ToolHook[]; private readonly confirmTools: string[]; private readonly confirmations: ConfirmationRegistry; + /** The hosted interaction kinds (raise/submit tools). Empty ⇒ no interaction tools. */ + private readonly interactions: InteractionRegistry; + /** Session-keyed park registry for in-flight Rich Interactions (one per connection). */ + private readonly interactionPark = new InteractionParkRegistry(); private readonly agentConfig?: AgentConfigResolver; private readonly judgeModel?: string; private readonly sessionAuthenticator?: SessionAuthenticator; @@ -159,6 +171,7 @@ export class FrameDispatcher { this.toolHooks = options.toolHooks ?? []; this.confirmTools = options.confirmTools ?? []; this.confirmations = options.confirmations ?? new ConfirmationRegistry(); + this.interactions = options.interactions ?? new InteractionRegistry(); this.agentConfig = options.agentConfig; this.judgeModel = options.judgeModel; this.sessionAuthenticator = options.sessionAuthenticator; @@ -189,6 +202,15 @@ export class FrameDispatcher { this.confirmations.rejectAll(); } + /** + * Resolve every outstanding Rich Interaction as `no_response`, unparking any turn + * awaiting a raise so it can finish (the visitor never answered the card). Called + * by the connection loop alongside {@link rejectPendingConfirmations} on teardown. + */ + rejectPendingInteractions(): void { + this.interactionPark.rejectAll(); + } + /** * Abort the connection's in-flight turn, if any, WITHOUT emitting anything. Returns * whether a turn was actually aborted. @@ -219,6 +241,9 @@ export class FrameDispatcher { // a connection-wide sweep: the disconnect path rejects every confirmation // separately via {@link rejectPendingConfirmations}. No-op when the turn isn't parked. this.confirmations.resolve(turn.sessionId, false); + // Same for a turn parked on a Rich Interaction raise: unblock it (no_response) + // so the park's `await` returns and the turn finishes. + this.interactionPark.resolve(turn.sessionId, { status: 'no_response' }); this.turns.delete(turn.promise); return true; } @@ -284,6 +309,9 @@ export class FrameDispatcher { case 'confirm_tool_action': this.handleConfirmToolAction(frame, requestId, sink); break; + case 'submit_interaction': + await this.handleSubmitInteraction(frame, requestId, sink); + break; case 'verify_otp': await this.handleVerifyOtp(frame, requestId, sink); break; @@ -405,12 +433,18 @@ export class FrameDispatcher { // single-tenant behavior, unchanged). const ownerEmail = this.access.authEnabled ? this.access.principal.email : typeof frame.userEmail === 'string' ? frame.userEmail : undefined; + // The client's declared render capabilities (`supports`) gate this session's + // Rich Interactions. Non-string entries are dropped (forward-compatible); an + // absent/empty list ⇒ a text-only channel (every kind falls back). + const supports = Array.isArray(frame.supports) ? frame.supports.filter((s): s is string => typeof s === 'string') : undefined; + const session = await this.store.createSession( agentId, typeof frame.userName === 'string' ? frame.userName : undefined, ownerEmail, conversationId, this.access.principal.org, + supports, ); // A freshly created session never passes through scopedSession, so associate here too. if (this.associate) { @@ -650,9 +684,18 @@ export class FrameDispatcher { // connection. Its eager tools join the base set BEFORE the enabled_tools filter, // so a per-agent allow-list drops them exactly like a built-in (SMOODEV-590 parity). const extHost = await buildExtensionHost({ confirmations: this.confirmations, sessionId, requestId: reqId, sink }); - // Static tools + this turn's SEP extension tools + per-turn host-provider tools. + // Rich Interactions: one `request_` raise tool per hosted kind, gated + // per-kind by the session's declared `supports`. A declared-capability kind + // parks the turn on a rich card (`interaction_required`); the rest degrade to + // their conversational fallback (backed by the generic `submit_interaction` + // tool). No hosted kinds ⇒ no interaction tools, behaviour unchanged. They join + // the base set BEFORE the enabled_tools filter, so a per-agent allow-list can + // restrict individual raise tools exactly like a built-in (mirrors the Rust server). + const interactionTools = this.buildInteractionTools(sessionId, reqId, sink, session.supports); + + // Static tools + this turn's SEP extension tools + per-turn host-provider tools + interaction tools. // All go through the same enabled-tools filter + auth gate below. - const baseTools = [...this.tools, ...(extHost ? extHost.tools() : []), ...hostTools]; + const baseTools = [...this.tools, ...(extHost ? extHost.tools() : []), ...hostTools, ...interactionTools]; const enabledTools = agentConfig?.enabledTools; const filteredTools = enabledTools?.length ? baseTools.filter((t) => enabledTools.some((e) => e.enabled && e.toolId === t.name)) @@ -748,6 +791,9 @@ export class FrameDispatcher { this.confirmations?.clear(sessionId); await extHost.shutdownAll(); } + // Drop any lingering parked interaction so a stale entry can't mis-route a + // later `submit_interaction` (mirrors the Rust `(cfg.clear)` at turn end). + this.interactionPark.clear(sessionId); } })(); // Track it as the connection's single active turn — unless it already finished @@ -796,6 +842,132 @@ export class FrameDispatcher { ); } + /** + * Build this turn's Rich Interaction tools: one `request_` raise tool per + * hosted kind (rich when the session declared the kind's capability, else the + * conversational fallback) plus the generic `submit_interaction` tool when any kind + * is on the fallback path. Empty when no kinds are hosted. A fresh per-turn + * `raisedSpecs` stash lets a same-turn fallback submit validate with full + * required-ness. Mirrors the Rust runner's interaction-bridge tool registration. + */ + private buildInteractionTools(sessionId: string, requestId: string, sink: Sink, supports: string[] | undefined): Tool[] { + const kinds = this.interactions.all(); + if (kinds.length === 0) return []; + const capabilities = new Set(supports ?? []); + const raisedSpecs: RaisedSpecs = new Map(); + const tools: Tool[] = []; + let anyFallback = false; + for (const kind of kinds) { + const rich = capabilities.has(kind.capability); + if (!rich) anyFallback = true; + tools.push(requestInteractionTool({ kind, rich, sessionId, requestId, sink, park: this.interactionPark, raisedSpecs })); + } + // The generic submit tool is only needed when at least one kind falls back + // (rich sessions submit via the `submit_interaction` protocol action instead). + if (anyFallback) tools.push(submitInteractionTool({ kinds: this.interactions, raisedSpecs })); + return tools; + } + + /** + * `submit_interaction` — resume a turn parked on a Rich Interaction. + * + * Per `spec/actions/submit-interaction.schema.json` the client replies with + * `{ action, sessionId, requestId, interactionId, kind?, values?, declined? }` to an + * `interaction_required` event. Validation is server-side, routed to the parked + * kind's validator against the spec the raise carried: + * - invalid → an `interaction_invalid` event with per-field errors; the turn STAYS + * parked so the card can resubmit (retryable, mirrors `otp_invalid` — never a + * terminal `error`); + * - valid → the parked raise resumes with the canonical values and an + * `immediate_response` acks; + * - `declined: true` → the raise resumes with a declined payload. + * + * The `interactionId` must echo the event's, so a stale submit can never resolve a + * newer park; peeking (not consuming) on an invalid submit keeps the turn parked, and + * resolving takes the pending out so a duplicate submit is a clean + * `NO_PENDING_INTERACTION` no-op. Not-yours collapses into `NO_PENDING_INTERACTION` + * (the identical response an id with no park produces) so a submit can't land in — or + * probe — another user's turn. + */ + private async handleSubmitInteraction(frame: Record, requestId: string | undefined, sink: Sink): Promise { + // requestId is load-bearing (it echoes the originating interaction_required); require it. + if (requestId === undefined) { + sink(protocol.error(undefined, 'VALIDATION_ERROR', "submit_interaction requires a 'requestId'")); + return; + } + const sessionId = typeof frame.sessionId === 'string' ? frame.sessionId : ''; + if (!sessionId) { + sink(protocol.error(requestId, 'VALIDATION_ERROR', "submit_interaction requires a 'sessionId'")); + return; + } + + // Peek the pending interaction WITHOUT consuming the park — an invalid submit must + // leave the turn parked for a resubmit. A session we may not read reports the + // identical event an id with no pending park produces. + const owned = (await this.scopedSession(sessionId)) !== undefined; + const pending = owned ? this.interactionPark.peek(sessionId) : undefined; + if (!pending) { + sink(protocol.error(requestId, 'NO_PENDING_INTERACTION', `no interaction is awaiting submission for session '${sessionId}'`)); + return; + } + + // The submit must target THIS interaction instance (and, when it names a kind, the + // right kind) — a stale card can never resolve a newer park. + const interactionId = typeof frame.interactionId === 'string' ? frame.interactionId : undefined; + if (interactionId !== pending.interactionId) { + sink(protocol.error(requestId, 'INTERACTION_MISMATCH', "the submitted 'interactionId' does not match the pending interaction")); + return; + } + if (typeof frame.kind === 'string' && frame.kind !== pending.kind) { + sink(protocol.error(requestId, 'INTERACTION_MISMATCH', `the pending interaction is '${pending.kind}', not '${frame.kind}'`)); + return; + } + + // Decline path: resume the raise with a declined payload. + if (frame.declined === true) { + if (this.resolveInteraction(sessionId, requestId, { status: 'declined' }, sink)) { + sink(protocol.immediateResponse(requestId, 200, 'Interaction declined', { sessionId, interactionId: pending.interactionId, declined: true })); + } + return; + } + + // Values path: route to the parked kind's server-side validator. + if (frame.values === undefined) { + sink(protocol.error(requestId, 'VALIDATION_ERROR', "submit_interaction requires 'values' (or 'declined': true)")); + return; + } + const kind = this.interactions.get(pending.kind); + if (!kind) { + // A parked kind the registry no longer hosts (shouldn't happen). + sink(protocol.error(requestId, 'NO_PENDING_INTERACTION', `interaction kind '${pending.kind}' is not hosted by this server`)); + return; + } + + const result = kind.validate(pending.spec, frame.values); + if (!result.ok) { + // Retryable: the turn stays parked; the client re-renders the card with the + // per-field errors (never a terminal `error` event). + sink(protocol.interactionInvalid(requestId, pending.interactionId, pending.kind, result.errors, 'Some fields need attention.')); + return; + } + if (this.resolveInteraction(sessionId, requestId, { status: 'submitted', values: result.values }, sink)) { + sink(protocol.immediateResponse(requestId, 200, 'Interaction submitted', { sessionId, interactionId: pending.interactionId, kind: pending.kind, values: result.values })); + } + } + + /** + * Consume the pending interaction for `sessionId` and feed it `outcome`. Returns + * `true` when the parked turn was resumed; emits `NO_PENDING_INTERACTION` and returns + * `false` when the park raced away (duplicate submit, or the parked turn ended first). + */ + private resolveInteraction(sessionId: string, requestId: string, outcome: InteractionOutcome, sink: Sink): boolean { + if (!this.interactionPark.resolve(sessionId, outcome)) { + sink(protocol.error(requestId, 'NO_PENDING_INTERACTION', `no interaction is awaiting submission for session '${sessionId}'`)); + return false; + } + return true; + } + /** * Emit the OTP-offer sequence for a turn whose `end_user` tool was refused for * lack of a verified session: `otp_verification_required` (prompt the client), diff --git a/typescript/server/src/index.ts b/typescript/server/src/index.ts index 1e13b1d7..41bfcb6a 100644 --- a/typescript/server/src/index.ts +++ b/typescript/server/src/index.ts @@ -27,6 +27,11 @@ export type { AccessKnowledge, FrameDispatcherOptions } from './frameDispatcher. export { ConfirmationRegistry } from './confirmation.js'; +export { InteractionParkRegistry, InteractionRegistry, INTERACTION_TIMEOUT_MS, requestInteractionTool, submitInteractionTool, SUBMIT_INTERACTION_TOOL } from './interaction.js'; +export type { InteractionFieldError, InteractionKind, InteractionOutcome, InteractionRequest, InteractionValidation, PendingInteraction, RaisedSpecs } from './interaction.js'; +export { ChoicesKind, HEADER_MAX_CHARS, parseQuestions, validateChoices } from './choices.js'; +export type { ChoiceAnswer, ChoiceFieldError, ChoiceOption, ChoiceQuestion, ChoiceValues } from './choices.js'; + export { DEFAULT_MAX_ITERATIONS, DEFAULT_MAX_TOKENS, DEFAULT_MODEL, DEFAULT_SYSTEM_PROMPT, TurnRunner } from './turnRunner.js'; export type { Sink, TurnResult, TurnRunnerOptions } from './turnRunner.js'; diff --git a/typescript/server/src/interaction.ts b/typescript/server/src/interaction.ts new file mode 100644 index 00000000..4a9e909a --- /dev/null +++ b/typescript/server/src/interaction.ts @@ -0,0 +1,340 @@ +/** + * Rich Interactions — the kind-agnostic park/resume framework. + * + * One pattern, many kinds: an agent raises a **structured interaction** (choice + * chips, an identity form, a date picker, …). On a channel whose client declared + * the kind's render capability (`supports` at `create_conversation_session`), the + * turn parks and the client renders a rich card (`interaction_required` → + * `submit_interaction`). On a text-only channel the same raise degrades to the + * kind's **conversational fallback**: a directive the model follows turn by turn, + * submitting through the generic `submit_interaction` *tool*. Both paths run the + * kind's **server-side validator** and resume the turn with the same canonical + * payload. + * + * The TypeScript port of the Rust reference `smooth-operator/src/interaction.rs` + * (the {@link InteractionKind} seam + {@link InteractionRegistry}) and + * `smooth-operator/src/tools/interaction.rs` (the raise/submit tools). The park + * registry ({@link InteractionParkRegistry}) is the interaction analog of the + * write-confirmation {@link ./confirmation.js} registry: a raise tool parks by + * awaiting a promise the `submit_interaction` action resolves. + * + * Adding a kind = implementing {@link InteractionKind} (one module, e.g. + * {@link ./choices.js}) and registering it in an {@link InteractionRegistry}. No + * new protocol events, no new client verbs. + */ +import { randomUUID } from 'node:crypto'; + +import type { Tool } from '@smooai/smooth-operator-core'; + +import * as protocol from './protocol.js'; +import type { Sink } from './turnRunner.js'; + +/** Wire name of the generic conversational submit tool (same verb as the resume action). */ +export const SUBMIT_INTERACTION_TOOL = 'submit_interaction'; + +/** + * How long a parked interaction waits for a `submit_interaction` action before the + * raise tool gives up and lets the turn continue without the details. Generous — a + * human is filling a card. + */ +export const INTERACTION_TIMEOUT_MS = 300_000; + +/** A single per-field validation failure, carried on `interaction_invalid` and the fallback tool error. */ +export interface InteractionFieldError { + /** The kind-specific field that failed (choices: the question `header`). */ + field: string; + /** Human-readable validation message. */ + message: string; +} + +/** A parsed raise: what the agent asked the visitor for. */ +export interface InteractionRequest { + /** The interaction kind (e.g. `choices`). */ + kind: string; + /** The kind-specific render spec (shape per `spec/interactions/.schema.json#/$defs/Spec`). */ + spec: unknown; + /** Why the agent raised it (card header / woven into the conversational ask). */ + reason: string; +} + +/** The result of a kind's server-side validator: canonical values or per-field errors. */ +export type InteractionValidation = { ok: true; values: unknown } | { ok: false; errors: InteractionFieldError[] }; + +/** How a parked interaction resolved. `no_response` covers a timeout / teardown / decline-to-answer. */ +export type InteractionOutcome = { status: 'submitted'; values: unknown } | { status: 'declined' } | { status: 'no_response' }; + +/** + * One interaction kind — the extension seam. A kind supplies exactly the pieces + * that differ per interaction; all park / resume / event / registry machinery is + * shared and kind-agnostic. Mirrors the Rust `InteractionKind` trait. + */ +export interface InteractionKind { + /** The wire kind id (e.g. `choices`). Selects the client card and the validator. */ + readonly kind: string; + /** The client render capability that gates the rich path (e.g. `choice_chips`). */ + readonly capability: string; + /** The raise tool's LLM-facing schema (per-kind). Convention: name it `request_`. */ + toolSchema(): { name: string; description: string; parameters: Record }; + /** Parse + canonicalize the raise tool's arguments into the kind's `spec` + reason. Throws on malformed args. */ + parseRequest(args: Record): InteractionRequest; + /** + * Validate submitted values against `spec`, returning the canonical values or + * every per-field error. `spec` may be `null` on the conversational path when + * the raise happened in an earlier turn — the kind then applies format-only + * validation. + */ + validate(spec: unknown, values: unknown): InteractionValidation; + /** The conversational-degradation directive for text-only channels. */ + fallbackDirective(spec: unknown, reason: string): string; +} + +/** + * The set of interaction kinds a server hosts. A host builds one with the kinds it + * offers (see `server.ts` wiring the reference `choices` kind). + */ +export class InteractionRegistry { + private readonly kinds = new Map(); + + constructor(kinds: InteractionKind[] = []) { + for (const kind of kinds) this.kinds.set(kind.kind, kind); + } + + /** Look up a kind by its wire id. */ + get(kind: string): InteractionKind | undefined { + return this.kinds.get(kind); + } + + /** Every registered kind, in registration order. */ + all(): InteractionKind[] { + return [...this.kinds.values()]; + } +} + +/** A parked interaction: the metadata the `submit_interaction` action validates against + the resolver. */ +interface Pending { + interactionId: string; + kind: string; + spec: unknown; + settled: boolean; + resolveFn(outcome: InteractionOutcome): void; +} + +/** The peekable identity of a pending interaction (the resolver stays private). */ +export interface PendingInteraction { + interactionId: string; + kind: string; + spec: unknown; +} + +/** + * Tracks the in-flight Rich Interaction each parked turn is waiting on, keyed by + * `sessionId` (at most one per session). One registry per connection — a + * `submit_interaction` frame and the parked turn it resumes are always on the same + * connection. The interaction analog of the write-confirmation registry. + * + * Single-threaded under the Node event loop, so no locking. `peek` reads without + * consuming (an invalid submit must leave the turn parked for a resubmit); `resolve` + * takes the pending out so a duplicate submit is a clean no-op. + */ +export class InteractionParkRegistry { + private readonly pending = new Map(); + + /** + * Register (and return) a fresh outcome promise for `sessionId`. Any prior pending + * interaction for the session is resolved `no_response` first, so a stale parked + * turn can never be left dangling and the newest raise always wins. + */ + park(sessionId: string, meta: PendingInteraction): Promise { + const prior = this.pending.get(sessionId); + if (prior && !prior.settled) { + prior.settled = true; + prior.resolveFn({ status: 'no_response' }); + } + let resolveFn!: (outcome: InteractionOutcome) => void; + const promise = new Promise((resolve) => { + resolveFn = resolve; + }); + this.pending.set(sessionId, { ...meta, settled: false, resolveFn }); + return promise; + } + + /** The pending interaction for `sessionId` WITHOUT consuming it, or `undefined` if none is parked. */ + peek(sessionId: string): PendingInteraction | undefined { + const p = this.pending.get(sessionId); + if (!p || p.settled) return undefined; + return { interactionId: p.interactionId, kind: p.kind, spec: p.spec }; + } + + /** + * Resolve the parked turn for `sessionId` with `outcome`. Returns `true` if a + * pending interaction was resolved, `false` if none was awaiting (a + * duplicate/stale submit → `NO_PENDING_INTERACTION`). + */ + resolve(sessionId: string, outcome: InteractionOutcome): boolean { + const p = this.pending.get(sessionId); + if (!p || p.settled) return false; + p.settled = true; + this.pending.delete(sessionId); + p.resolveFn(outcome); + return true; + } + + /** Drop any registered interaction for `sessionId` (turn ended). Idempotent. */ + clear(sessionId: string): void { + this.pending.delete(sessionId); + } + + /** + * Resolve every outstanding interaction as `no_response` — called when a + * connection is torn down so any turn parked on a raise unparks and finishes + * cleanly (the visitor simply didn't answer the card). + */ + rejectAll(): void { + for (const p of this.pending.values()) { + if (!p.settled) { + p.settled = true; + p.resolveFn({ status: 'no_response' }); + } + } + this.pending.clear(); + } +} + +/** The per-turn stash of specs raised on the conversational path, so the generic submit tool validates with full required-ness. */ +export type RaisedSpecs = Map; + +/** + * Build the per-kind raise tool (`request_`). On a session that declared the + * kind's render capability (`rich`) it **parks the turn**: it registers the outcome + * resolver, emits `interaction_required`, and awaits the visitor's submit. Otherwise + * it returns immediately with the kind's conversational-fallback directive (and + * stashes the spec so a same-turn generic `submit_interaction` validates with + * required-ness). Mirrors the Rust `RequestInteractionTool`. + */ +export function requestInteractionTool(opts: { + kind: InteractionKind; + rich: boolean; + sessionId: string; + requestId: string; + sink: Sink; + park: InteractionParkRegistry; + raisedSpecs: RaisedSpecs; + timeoutMs?: number; +}): Tool { + const { kind, rich, sessionId, requestId, sink, park, raisedSpecs } = opts; + const timeoutMs = opts.timeoutMs ?? INTERACTION_TIMEOUT_MS; + const schema = kind.toolSchema(); + return { + name: schema.name, + description: schema.description, + parameters: schema.parameters, + async execute(args: Record): Promise { + // A malformed raise throws; the engine surfaces it to the model as a tool + // error (never crashes the turn), so the model can correct and retry. + const request = kind.parseRequest(args); + + if (!rich) { + // Text-only channel: degrade to the kind's conversational directive. + raisedSpecs.set(request.kind, request.spec); + return JSON.stringify({ + mode: 'conversational', + kind: request.kind, + spec: request.spec, + reason: request.reason, + instructions: kind.fallbackDirective(request.spec, request.reason), + }); + } + + // Rich channel: park the turn. Register the resolver, emit the prompt, then + // await the client's `submit_interaction` (or a timeout / teardown). + const interactionId = randomUUID(); + const outcome = park.park(sessionId, { interactionId, kind: request.kind, spec: request.spec }); + sink(protocol.interactionRequired(requestId, interactionId, request.kind, request.spec, request.reason)); + + const resolved = await withTimeout(outcome, timeoutMs, () => park.clear(sessionId)); + switch (resolved.status) { + case 'submitted': + return JSON.stringify({ status: 'submitted', values: resolved.values }); + case 'declined': + return JSON.stringify({ + status: 'declined', + message: 'The visitor declined. Continue helping them without this and do not ask again this conversation.', + }); + default: + return JSON.stringify({ + status: 'no_response', + message: 'The visitor did not respond to the card. Continue without it; you may offer again later if it becomes relevant.', + }); + } + }, + }; +} + +/** + * Build the generic `submit_interaction` tool — the conversational fallback's submit + * half, one instance per turn regardless of how many kinds are hosted. Routes to the + * parked kind's server-side validator; invalid values throw a per-field tool error + * the model relays and re-asks; valid values return the **identical** canonical + * payload the rich path resumes with. Mirrors the Rust `SubmitInteractionTool`. + */ +export function submitInteractionTool(opts: { kinds: InteractionRegistry; raisedSpecs: RaisedSpecs }): Tool { + const { kinds, raisedSpecs } = opts; + const kindIds = kinds.all().map((k) => k.kind); + return { + name: SUBMIT_INTERACTION_TOOL, + description: + 'Submit the visitor\'s answers collected conversationally after a request_* interaction directive. Values ' + + 'are validated server-side; on a validation error, apologize, re-ask for the corrected field, and submit ' + + 'again. If the visitor declined, set declined=true.', + parameters: { + type: 'object', + properties: { + kind: { type: 'string', enum: kindIds, description: 'The interaction kind being submitted (from the directive).' }, + values: { type: 'object', description: 'The collected values, shaped per the interaction kind.' }, + declined: { type: 'boolean', description: 'True when the visitor declined the interaction.' }, + }, + required: ['kind'], + }, + async execute(args: Record): Promise { + const kindId = typeof args.kind === 'string' ? args.kind : ''; + if (!kindId) throw new Error("'kind' is required"); + const kind = kinds.get(kindId); + if (!kind) throw new Error(`unknown interaction kind '${kindId}'`); + + if (args.declined === true) { + return JSON.stringify({ + status: 'declined', + message: 'Noted. Continue helping the visitor without this and do not ask again this conversation.', + }); + } + + const values = args.values ?? null; + // The spec raised earlier this turn (full required-ness) — or null (a + // prior-turn raise): the kind then validates format-only. + const spec = raisedSpecs.get(kindId) ?? null; + const result = kind.validate(spec, values); + if (result.ok) return JSON.stringify({ status: 'submitted', values: result.values }); + const detail = result.errors.map((e) => `${e.field}: ${e.message}`).join('; '); + throw new Error(`validation failed — ${detail}. Re-ask the visitor for the corrected value(s) and submit again.`); + }, + }; +} + +/** + * Race a park promise against a timeout. On timeout, run `onTimeout` (clears the + * registration) and resolve `no_response` — the visitor never answered the card. + */ +function withTimeout(promise: Promise, timeoutMs: number, onTimeout: () => void): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + onTimeout(); + resolve({ status: 'no_response' }); + }, timeoutMs); + // Don't keep the process alive on a parked card's long timer. + if (typeof timer.unref === 'function') timer.unref(); + void promise.then((outcome) => { + clearTimeout(timer); + resolve(outcome); + }); + }); +} diff --git a/typescript/server/src/postgresStore.ts b/typescript/server/src/postgresStore.ts index 5b9507a1..b457583e 100644 --- a/typescript/server/src/postgresStore.ts +++ b/typescript/server/src/postgresStore.ts @@ -175,6 +175,8 @@ interface SessionMetadata { contactPhone?: string; otpVerified?: boolean; currentStepId?: string; + /** The session's declared render capabilities (`supports`) — the Rich Interactions gate. */ + supports?: string[]; } /** ISO-8601 in UTC, the shape every timestamp crosses this interface as. */ @@ -211,7 +213,7 @@ export class PostgresStore implements SessionStore, AdminStore { * another org's — mints a fresh conversation through the identical branch, so a * caller cannot use resume as an oracle for which conversation ids exist. */ - async createSession(agentId: string, userName?: string, userEmail?: string, conversationId?: string, orgId: string = DEFAULT_ORG_ID): Promise { + async createSession(agentId: string, userName?: string, userEmail?: string, conversationId?: string, orgId: string = DEFAULT_ORG_ID, supports?: string[]): Promise { const owner = userEmail?.trim() || undefined; let resumeId: string | undefined; @@ -242,6 +244,8 @@ export class PostgresStore implements SessionStore, AdminStore { ...(resumeId ? (resumedOwner ? { userEmail: resumedOwner } : {}) : owner ? { userEmail: owner } : {}), // The caller's email doubles as the OTP delivery contact. ...(owner ? { contactEmail: owner } : {}), + // The declared render capabilities gate this session's Rich Interactions. + ...(supports && supports.length > 0 ? { supports } : {}), }; const now = new Date().toISOString(); @@ -271,7 +275,10 @@ export class PostgresStore implements SessionStore, AdminStore { [session.agentParticipantId, convId, orgId, AGENT_NAME, now], ); } - const metadata: SessionMetadata = owner ? { contactEmail: owner } : {}; + const metadata: SessionMetadata = { + ...(owner ? { contactEmail: owner } : {}), + ...(supports && supports.length > 0 ? { supports } : {}), + }; await client.query( `INSERT INTO conversation_sessions (session_id, conversation_id, organization_id, agent_id, agent_name, user_participant_id, @@ -329,6 +336,7 @@ export class PostgresStore implements SessionStore, AdminStore { ...(metadata.contactPhone ? { contactPhone: metadata.contactPhone } : {}), ...(metadata.otpVerified ? { otpVerified: true } : {}), ...(metadata.currentStepId ? { currentStepId: metadata.currentStepId } : {}), + ...(metadata.supports && metadata.supports.length > 0 ? { supports: metadata.supports } : {}), }; } diff --git a/typescript/server/src/protocol.ts b/typescript/server/src/protocol.ts index 3d8bb53f..42d9d595 100644 --- a/typescript/server/src/protocol.ts +++ b/typescript/server/src/protocol.ts @@ -173,6 +173,55 @@ export function writeConfirmationRequired(requestId: string, toolId: string, act }; } +/** + * `interaction_required` — emitted mid-turn when the agent raises a Rich + * Interaction on a session that declared the kind's render capability. The turn is + * **parked** (the raise tool awaits the visitor's pick) until the client replies + * with a `submit_interaction` action echoing the same `interactionId`. + * + * Wire shape matches `spec/events/interaction-required.schema.json` and the + * Rust/Python reference servers byte-for-byte: the `requestId` echoes the + * originating `send_message`, and the prompt detail is double-nested under + * `data.data.{interactionId, kind, spec, reason}`. `spec` is the kind-specific + * render spec the client card is built from; `reason` is why the agent raised it. + */ +export function interactionRequired(requestId: string, interactionId: string, kind: string, spec: unknown, reason: string): Frame { + return { + type: 'interaction_required', + requestId, + data: { + requestId, + data: { interactionId, kind, spec, reason }, + }, + timestamp: nowMs(), + }; +} + +/** + * `interaction_invalid` — emitted when a `submit_interaction` fails the kind's + * server-side validation. Retryable: the turn STAYS parked so the client can + * re-render the card with the per-field errors and resubmit (never a terminal + * `error` event — mirrors `otp_invalid`). Wire shape matches + * `spec/events/interaction-invalid.schema.json` (double-nested `data.data`), with + * `errors` a list of `{ field, message }` and a human-readable `message`. + */ +export function interactionInvalid(requestId: string, interactionId: string, kind: string, errors: { field: string; message: string }[], message: string): Frame { + return { + type: 'interaction_invalid', + requestId, + data: { + requestId, + data: { + interactionId, + kind, + errors: errors.map((e) => ({ field: e.field, message: e.message })), + message, + }, + }, + timestamp: nowMs(), + }; +} + /** * `otp_verification_required` — emitted after a turn's auth gate refused an * `end_user` tool on an unverified session and the host has an OTP service diff --git a/typescript/server/src/server.ts b/typescript/server/src/server.ts index 8b26deed..496d9ffe 100644 --- a/typescript/server/src/server.ts +++ b/typescript/server/src/server.ts @@ -36,6 +36,8 @@ import type { AuthVerifier } from './auth.js'; import { NoAuthVerifier } from './auth.js'; import { InMemoryAdminStore, type AdminStore, handleAdminRequest } from './admin.js'; import { InMemorySessionStore, type SessionStore } from './sessionStore.js'; +import { InteractionRegistry } from './interaction.js'; +import { ChoicesKind } from './choices.js'; export interface ServerOptions { /** The OpenAI-compatible engine client (gateway in prod, a mock in tests). */ @@ -117,6 +119,15 @@ export interface ServerOptions { * `SKILL_NOT_FOUND`, so a multi-tenant deploy never serves host skills by accident. */ skillResolver?: SkillResolver; + /** + * The Rich Interactions the server hosts (see `interaction.ts`). Each turn registers + * one `request_` raise tool per kind, gated per-kind by the session's declared + * `supports`: a declared-capability kind parks the turn on a rich card + * (`interaction_required` → `submit_interaction`), the rest degrade to their + * conversational fallback. Defaults to the reference catalog — the `choices` kind + * (AskUserQuestion). Pass an empty `new InteractionRegistry()` to host none. + */ + interactions?: InteractionRegistry; /** WS path to mount on (default `/ws`). */ path?: string; } @@ -148,6 +159,9 @@ export function buildServer(options: ServerOptions): { } { const store = options.store ?? new InMemorySessionStore(); const auth = options.auth ?? new NoAuthVerifier(); + // The reference catalog: the `choices` kind (AskUserQuestion). Always wired unless the + // host overrides it — rich-vs-fallback is decided per kind from the session's `supports`. + const interactions = options.interactions ?? new InteractionRegistry([new ChoicesKind()]); const backplane = options.backplane ?? new InMemoryBackplane(); const path = options.path ?? '/ws'; @@ -180,6 +194,7 @@ export function buildServer(options: ServerOptions): { tools: options.tools, toolHooks: options.toolHooks, confirmTools: options.confirmTools, + interactions, agentConfig: options.agentConfig, judgeModel: options.judgeModel, sessionAuthenticator: options.sessionAuthenticator, @@ -404,6 +419,7 @@ async function runConnection(socket: WebSocket, dispatcher: FrameDispatcher, bac if (socketClosed) dispatcher.cancelActiveTurn(); dispatcher.rejectPendingConfirmations(); + dispatcher.rejectPendingInteractions(); await dispatcher.waitForTurns(); // Stop the writer and let it flush what's queued, then close the socket one-way. diff --git a/typescript/server/src/sessionStore.ts b/typescript/server/src/sessionStore.ts index 46698c94..0b9d378c 100644 --- a/typescript/server/src/sessionStore.ts +++ b/typescript/server/src/sessionStore.ts @@ -77,6 +77,15 @@ export interface StoredSession { * reference server's `session.metadata.otpVerified`. */ otpVerified?: boolean; + /** + * The client render capabilities this session declared at create-session + * (`supports`) — the per-kind gate for Rich Interactions. A kind whose + * `capability` is present here parks the turn on a rich card + * (`interaction_required`); anything else degrades to the kind's conversational + * fallback. `undefined`/empty → a text-only channel (every kind falls back). The + * TS analog of the Rust reference server's `session_capabilities`. + */ + supports?: string[]; } /** Whether a stored message came from the user (`inbound`) or the agent (`outbound`). */ @@ -119,7 +128,7 @@ export interface SessionStore { * so subsequent turns append and history replays). An absent or unknown id mints * a fresh conversation (unchanged behavior). */ - createSession(agentId: string, userName?: string, userEmail?: string, conversationId?: string, orgId?: string): Promise; + createSession(agentId: string, userName?: string, userEmail?: string, conversationId?: string, orgId?: string, supports?: string[]): Promise; getSession(sessionId: string): Promise; /** * A conversation by id, or null if unknown — the resume-binding existence check. @@ -189,7 +198,7 @@ export class InMemorySessionStore implements SessionStore { */ private readonly convOrg = new Map(); - async createSession(agentId: string, _userName?: string, userEmail?: string, conversationId?: string, orgId?: string): Promise { + async createSession(agentId: string, _userName?: string, userEmail?: string, conversationId?: string, orgId?: string, supports?: string[]): Promise { // Resume: bind to an existing conversation (reuse its id + persisted log) when // the caller passes a known conversationId. Unknown/absent → mint a fresh one. const resume = conversationId && this.messages.has(conversationId); @@ -210,6 +219,9 @@ export class InMemorySessionStore implements SessionStore { // Stash the caller's email as an OTP delivery contact for the end_user // auth-gate flow (mirrors the Rust reference capturing contactEmail). ...(userEmail ? { contactEmail: userEmail } : {}), + // The declared render capabilities gate this session's Rich Interactions. + // Empty/absent ⇒ a text-only channel (every kind falls back). + ...(supports && supports.length > 0 ? { supports } : {}), }; this.sessions.set(session.sessionId, session); // Only initialize the message log on a fresh conversation — a resume keeps its history. diff --git a/typescript/server/test/choices.test.ts b/typescript/server/test/choices.test.ts new file mode 100644 index 00000000..c261fe72 --- /dev/null +++ b/typescript/server/test/choices.test.ts @@ -0,0 +1,184 @@ +/** + * The `choices` Rich Interaction kind — validator + kind wiring + the shared + * conformance fixtures. + * + * The TS parity of the Rust `choices.rs` unit tests: the validator's one-pass + * per-question rules, the raise-tool argument contract (`parseQuestions`), the + * kind's `parseRequest`/`validate`/`fallbackDirective`, and the `interaction_required` + * protocol builder — all cross-checked against the three shared `choices` fixtures + * in `spec/conformance/fixtures.json` (so a drift in any one server is caught here). + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { ChoicesKind, HEADER_MAX_CHARS, parseQuestions, validateChoices, type ChoiceQuestion, type ChoiceValues } from '../src/choices.js'; +import * as protocol from '../src/protocol.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SPEC_DIR = join(__dirname, '..', '..', '..', 'spec'); +const fixtures = JSON.parse(readFileSync(join(SPEC_DIR, 'conformance', 'fixtures.json'), 'utf8')) as Record } | string>; + +function fixture(name: string): Record { + const entry = fixtures[name]; + if (!entry || typeof entry === 'string') throw new Error(`missing fixture ${name}`); + return entry.instance; +} + +function q(header: string, labels: string[], multiSelect = false): ChoiceQuestion { + return { question: `${header}?`, header, options: labels.map((l) => ({ label: l, description: '' })), multiSelect }; +} +function vals(answers: ChoiceValues['answers']): ChoiceValues { + return { answers }; +} + +describe('validateChoices', () => { + it('single-select normalizes (trims labels)', () => { + const out = validateChoices([q('Plan', ['Basic', 'Pro'])], vals([{ header: 'Plan', options: [' Pro '] }])); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.values.answers).toHaveLength(1); + expect(out.values.answers[0]!.options).toEqual(['Pro']); + expect(out.values.answers[0]!.other).toBeUndefined(); + } + }); + + it('multi-select keeps all picks', () => { + const out = validateChoices([q('Topics', ['Sales', 'Support', 'Billing'], true)], vals([{ header: 'Topics', options: ['Sales', 'Billing'] }])); + expect(out.ok).toBe(true); + if (out.ok) expect(out.values.answers[0]!.options).toEqual(['Sales', 'Billing']); + }); + + it("accepts the free-text 'Other' escape hatch and trims it; blank Other dropped", () => { + const out = validateChoices([q('Plan', ['Basic', 'Pro'])], vals([{ header: 'Plan', options: [], other: ' Enterprise, actually ' }])); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.values.answers[0]!.options).toEqual([]); + expect(out.values.answers[0]!.other).toBe('Enterprise, actually'); + } + const blank = validateChoices([q('Plan', ['Basic', 'Pro'])], vals([{ header: 'Plan', options: ['Pro'], other: ' ' }])); + expect(blank.ok).toBe(true); + if (blank.ok) expect(blank.values.answers[0]!.other).toBeUndefined(); + }); + + it('unknown label is a per-question error', () => { + const out = validateChoices([q('Plan', ['Basic', 'Pro'])], vals([{ header: 'Plan', options: ['Platinum'] }])); + expect(out.ok).toBe(false); + if (!out.ok) { + expect(out.errors).toHaveLength(1); + expect(out.errors[0]!.field).toBe('Plan'); + expect(out.errors[0]!.message).toContain('not one of the offered'); + } + }); + + it('single-select rejects multiple picks', () => { + const out = validateChoices([q('Plan', ['Basic', 'Pro'])], vals([{ header: 'Plan', options: ['Basic', 'Pro'] }])); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.some((e) => e.message.includes('single answer'))).toBe(true); + }); + + it('every question must be answered', () => { + const out = validateChoices([q('Plan', ['Basic', 'Pro']), q('Size', ['S', 'M'])], vals([{ header: 'Plan', options: ['Pro'] }])); + expect(out.ok).toBe(false); + if (!out.ok) { + expect(out.errors).toHaveLength(1); + expect(out.errors[0]!.field).toBe('Size'); + expect(out.errors[0]!.message).toContain('must be answered'); + } + }); + + it('an empty answer needs a pick or an other', () => { + const out = validateChoices([q('Plan', ['Basic', 'Pro'])], vals([{ header: 'Plan', options: [] }])); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.some((e) => e.message.includes('select an option'))).toBe(true); + }); + + it('format-only path (no questions) accepts any answer with a pick', () => { + const out = validateChoices([], vals([{ header: 'Anything', options: ['whatever'] }])); + expect(out.ok).toBe(true); + const empty = validateChoices([], vals([])); + expect(empty.ok).toBe(false); + }); +}); + +describe('parseQuestions — the raise-tool contract', () => { + it('accepts shorthand string options and defaults multiSelect to false', () => { + const qs = parseQuestions([{ question: 'Which plan?', header: 'Plan', options: ['Basic', 'Pro'] }]); + expect(qs).toHaveLength(1); + expect(qs[0]!.options[0]!.label).toBe('Basic'); + expect(qs[0]!.multiSelect).toBe(false); + }); + + it('rejects >4 questions, <2 options, an over-long header, and duplicate headers', () => { + expect(() => parseQuestions(Array.from({ length: 5 }, (_, i) => ({ question: 'q', header: `H${i}`, options: ['a', 'b'] })))).toThrow(/between 1 and 4/); + expect(() => parseQuestions([{ question: 'q', header: 'H', options: ['only'] }])).toThrow(/between 2 and 4/); + expect(() => parseQuestions([{ question: 'q', header: 'ThisHeaderIsWayTooLong', options: ['a', 'b'] }])).toThrow(/too long/); + expect(() => parseQuestions([{ question: 'q1', header: 'H', options: ['a', 'b'] }, { question: 'q2', header: 'H', options: ['a', 'b'] }])).toThrow(/duplicate/); + }); + + it(`caps the header at ${HEADER_MAX_CHARS} characters`, () => { + expect(() => parseQuestions([{ question: 'q', header: 'x'.repeat(HEADER_MAX_CHARS + 1), options: ['a', 'b'] }])).toThrow(/too long/); + expect(parseQuestions([{ question: 'q', header: 'x'.repeat(HEADER_MAX_CHARS), options: ['a', 'b'] }])).toHaveLength(1); + }); +}); + +describe('ChoicesKind', () => { + it('exposes the reference identity + raise-tool surface', () => { + const kind = new ChoicesKind(); + expect(kind.kind).toBe('choices'); + expect(kind.capability).toBe('choice_chips'); + expect(kind.toolSchema().name).toBe('request_choices'); + }); + + it('parseRequest canonicalizes questions + reason', () => { + const req = new ChoicesKind().parseRequest({ + questions: [{ question: 'Which plan interests you?', header: 'Plan', options: [{ label: 'Basic' }, { label: 'Pro' }] }], + reason: 'to route you', + }); + expect(req.kind).toBe('choices'); + expect(req.reason).toBe('to route you'); + expect((req.spec as { questions: ChoiceQuestion[] }).questions[0]!.header).toBe('Plan'); + }); + + it('fallbackDirective enumerates the options and points at submit_interaction', () => { + const kind = new ChoicesKind(); + const req = kind.parseRequest({ questions: [{ question: 'Which plan?', header: 'Plan', options: ['Basic', 'Pro'] }], reason: 'to route you' }); + const directive = kind.fallbackDirective(req.spec, 'to route you'); + expect(directive).toContain('Basic, Pro'); + expect(directive).toContain('submit_interaction'); + }); +}); + +describe('the shared choices fixtures', () => { + it('validate(choices_spec, choices_values) produces choices_payload.values', () => { + const spec = fixture('choices_spec'); + const values = fixture('choices_values'); + const payload = fixture('choices_payload'); + const out = new ChoicesKind().validate(spec, values); + expect(out.ok).toBe(true); + if (out.ok) expect(out.values).toEqual((payload as { values: unknown }).values); + }); + + it('interactionRequired builder matches the interaction_required_event shape, carrying the choices spec', () => { + const spec = fixture('choices_spec'); + const built = protocol.interactionRequired('req-a1b2c3d4-0004', '88888888-8888-8888-8888-888888888888', 'choices', spec, 'to route you'); + const rt = JSON.parse(JSON.stringify(built)) as Record; + expect(rt.type).toBe('interaction_required'); + expect(rt.requestId).toBe('req-a1b2c3d4-0004'); + const inner = (rt.data as { data: Record }).data; + expect(inner.interactionId).toBe('88888888-8888-8888-8888-888888888888'); + expect(inner.kind).toBe('choices'); + expect(inner.spec).toEqual(spec); + expect(inner.reason).toBe('to route you'); + }); + + it('interactionInvalid builder carries per-field errors and stays retryable-shaped', () => { + const built = protocol.interactionInvalid('req-1', 'i-1', 'choices', [{ field: 'Plan', message: 'this question must be answered' }], 'Some fields need attention.'); + const inner = (built.data as { data: Record }).data; + expect(built.type).toBe('interaction_invalid'); + expect((inner.errors as unknown[])).toHaveLength(1); + expect((inner.errors as { field: string }[])[0]!.field).toBe('Plan'); + expect(inner.message).toBe('Some fields need attention.'); + }); +}); diff --git a/typescript/server/test/submit-interaction.test.ts b/typescript/server/test/submit-interaction.test.ts new file mode 100644 index 00000000..db2049ae --- /dev/null +++ b/typescript/server/test/submit-interaction.test.ts @@ -0,0 +1,216 @@ +/** + * Rich Interactions (`choices` kind) end-to-end over the WS server — the raise → + * `interaction_required` → `submit_interaction` → resume path, plus the retryable + * `interaction_invalid` and the text-only conversational fallback. + * + * Boots the real TS WS server (the `choices` kind is hosted by default) with a + * scripted {@link MockLlmProvider} that calls `request_choices` on turn one, then + * drives the full seam over a real `ws` client — the TS parity of the Rust server's + * interaction integration coverage. The `submit_interaction` frame arrives on the + * same connection's reader while the turn is parked, proving the turn runs as a + * background task. Cross-checked against the shared `choices` fixtures. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { MockLlmProvider } from '@smooai/smooth-operator-core'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { ChoicesKind } from '../src/choices.js'; +import { serve, type RunningServer } from '../src/server.js'; +import { TestClient } from './wsClient.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SPEC_DIR = join(__dirname, '..', '..', '..', 'spec'); +const fixtures = JSON.parse(readFileSync(join(SPEC_DIR, 'conformance', 'fixtures.json'), 'utf8')) as Record } | string>; +function fixture(name: string): Record { + const entry = fixtures[name]; + if (!entry || typeof entry === 'string') throw new Error(`missing fixture ${name}`); + return entry.instance; +} + +const CHOICES_SPEC = fixture('choices_spec'); +const CHOICES_VALUES = fixture('choices_values'); +const CHOICES_PAYLOAD = fixture('choices_payload'); + +/** Turn 1 raises the `choices` interaction; turn 2 wraps up after it resolves. */ +function scriptedMock(): MockLlmProvider { + const mock = new MockLlmProvider(); + mock.pushToolCall('call-1', 'request_choices', JSON.stringify({ questions: (CHOICES_SPEC as { questions: unknown }).questions, reason: 'to route you to the right team' })); + mock.pushText('Thanks — routing you now.'); + return mock; +} + +async function start(): Promise { + return serve({ chatClient: scriptedMock() }); +} + +/** Drive create_conversation_session with the given render capabilities and return the session id. */ +async function createSession(client: TestClient, supports?: string[]): Promise { + client.sendAction({ + action: 'create_conversation_session', + requestId: 'r-create', + agentId: '11111111-1111-1111-1111-111111111111', + userName: 'Alice', + userEmail: 'alice@example.com', + ...(supports ? { supports } : {}), + }); + for (;;) { + const event = await client.receive(); + if (event.type === 'immediate_response') return (event.data as { sessionId: string }).sessionId; + } +} + +function innerData(event: Record): Record { + return (event.data as { data: Record }).data; +} + +describe('Rich Interactions — choices raise / submit_interaction / resume', () => { + let server: RunningServer | undefined; + afterEach(async () => { + await server?.close(); + server = undefined; + }); + + it('rich channel: raise parks, submit resumes with the canonical payload, turn completes', async () => { + server = await start(); + const client = await TestClient.connect(server.url); + try { + const sessionId = await createSession(client, ['choice_chips']); + client.sendAction({ action: 'send_message', requestId: 'r-msg', sessionId, message: 'I need help choosing' }); + + // The turn parks on the raise: wait for interaction_required. + const { terminal: required } = await client.receiveUntil('interaction_required'); + expect(required.requestId).toBe('r-msg'); + const prompt = innerData(required); + expect(prompt.kind).toBe('choices'); + // The carried spec is the CANONICAL form (every question stamped with + // multiSelect + each option with a description), so it is richer than the + // sample fixture — assert the load-bearing shape rather than byte-equality. + const questions = (prompt.spec as { questions: { header: string; options: { label: string }[]; multiSelect: boolean }[] }).questions; + expect(questions.map((q) => q.header)).toEqual(['Plan', 'Topics']); + expect(questions[0]!.options.map((o) => o.label)).toEqual(['Basic', 'Pro']); + expect(questions[1]!.multiSelect).toBe(true); + // The canonical spec still validates the shared values into the shared payload. + expect(new ChoicesKind().validate(prompt.spec, CHOICES_VALUES).ok).toBe(true); + expect(typeof prompt.reason).toBe('string'); + const interactionId = prompt.interactionId as string; + expect(interactionId.length).toBeGreaterThan(0); + + // Submit the visitor's picks (the shared values fixture) — arrives on the reader + // while the turn is parked, proving the turn runs as a background task. + client.sendAction({ action: 'submit_interaction', requestId: 'r-msg', sessionId, interactionId, kind: 'choices', values: CHOICES_VALUES }); + + let sawAck = false; + let resumedToolResult: string | undefined; + for (;;) { + const event = await client.receive(); + if (event.type === 'immediate_response' && event.status === 200) { + sawAck = true; + const data = event.data as { kind?: string; values?: unknown }; + expect(data.kind).toBe('choices'); + // The ack carries the canonical, validated payload values. + expect(data.values).toEqual((CHOICES_PAYLOAD as { values: unknown }).values); + } else if (event.type === 'stream_chunk') { + const tr = (event.data as { state?: { rawResponse?: { toolResult?: { name: string; result: string } } } }).state?.rawResponse?.toolResult; + if (tr?.name === 'request_choices') resumedToolResult = tr.result; + } else if (event.type === 'eventual_response') { + const inner = (event.data as { data: { response: { responseParts: string[] } } }).data; + expect(inner.response.responseParts).toEqual(['Thanks — routing you now.']); + break; + } + } + expect(sawAck).toBe(true); + // The parked raise tool resumed with the submitted values — the model saw them. + expect(resumedToolResult).toBeDefined(); + expect(resumedToolResult).toContain('submitted'); + expect(resumedToolResult).toContain('Partnerships'); + } finally { + await client.close(); + } + }); + + it('invalid submit → interaction_invalid (retryable, stays parked); a corrected submit then resumes', async () => { + server = await start(); + const client = await TestClient.connect(server.url); + try { + const sessionId = await createSession(client, ['choice_chips']); + client.sendAction({ action: 'send_message', requestId: 'r-msg', sessionId, message: 'help' }); + const { terminal: required } = await client.receiveUntil('interaction_required'); + const interactionId = innerData(required).interactionId as string; + + // A pick that isn't offered (and a missing question) → interaction_invalid, not a + // terminal error, and the turn stays parked. + client.sendAction({ action: 'submit_interaction', requestId: 'r-msg', sessionId, interactionId, kind: 'choices', values: { answers: [{ header: 'Plan', options: ['Platinum'] }] } }); + const { terminal: invalid } = await client.receiveUntil('interaction_invalid'); + const detail = innerData(invalid); + expect(detail.interactionId).toBe(interactionId); + expect(detail.kind).toBe('choices'); + expect((detail.errors as { field: string }[]).some((e) => e.field === 'Plan')).toBe(true); + + // Resubmit correctly → the still-parked turn resumes and completes. + client.sendAction({ action: 'submit_interaction', requestId: 'r-msg', sessionId, interactionId, kind: 'choices', values: CHOICES_VALUES }); + const { terminal: done, seen } = await client.receiveUntil('eventual_response'); + expect(done.status).toBe(200); + expect(seen.some((e) => e.type === 'immediate_response' && e.status === 200)).toBe(true); + } finally { + await client.close(); + } + }); + + it('submit with a mismatched interactionId → INTERACTION_MISMATCH (never resolves a stale park)', async () => { + server = await start(); + const client = await TestClient.connect(server.url); + try { + const sessionId = await createSession(client, ['choice_chips']); + client.sendAction({ action: 'send_message', requestId: 'r-msg', sessionId, message: 'help' }); + const { terminal: required } = await client.receiveUntil('interaction_required'); + client.sendAction({ action: 'submit_interaction', requestId: 'r-msg', sessionId, interactionId: 'not-the-right-id', kind: 'choices', values: CHOICES_VALUES }); + const { terminal: err } = await client.receiveUntil('error'); + expect((err.error as { code: string }).code).toBe('INTERACTION_MISMATCH'); + // The turn is still parked (a stale id never resolved it): a correct submit still + // resolves it and the turn completes — no hang. + const interactionId = innerData(required).interactionId as string; + client.sendAction({ action: 'submit_interaction', requestId: 'r-msg', sessionId, interactionId, kind: 'choices', values: CHOICES_VALUES }); + const { terminal: done } = await client.receiveUntil('eventual_response'); + expect(done.status).toBe(200); + } finally { + await client.close(); + } + }); + + it('submit with no parked turn → NO_PENDING_INTERACTION', async () => { + server = await start(); + const client = await TestClient.connect(server.url); + try { + const sessionId = await createSession(client, ['choice_chips']); + client.sendAction({ action: 'submit_interaction', requestId: 'r-x', sessionId, interactionId: 'whatever', values: CHOICES_VALUES }); + const { terminal: err } = await client.receiveUntil('error'); + expect((err.error as { code: string }).code).toBe('NO_PENDING_INTERACTION'); + } finally { + await client.close(); + } + }); + + it('text-only channel (no supports): the raise degrades to the conversational fallback — no interaction_required', async () => { + server = await start(); + const client = await TestClient.connect(server.url); + try { + const sessionId = await createSession(client); // no `supports` → text-only + client.sendAction({ action: 'send_message', requestId: 'r-msg', sessionId, message: 'help me pick' }); + const { terminal: done, seen } = await client.receiveUntil('eventual_response'); + expect(done.status).toBe(200); + // The rich card was never emitted — the turn ran to completion conversationally. + expect(seen.some((e) => e.type === 'interaction_required')).toBe(false); + // The raise tool returned the fallback directive, which the model saw. + const directive = seen + .map((e) => (e.data as { state?: { rawResponse?: { toolResult?: { name: string; result: string } } } }).state?.rawResponse?.toolResult) + .find((tr) => tr?.name === 'request_choices')?.result; + expect(directive).toBeDefined(); + expect(directive).toContain('conversational'); + expect(directive).toContain('submit_interaction'); + } finally { + await client.close(); + } + }); +});