From cfd71b401a1e2565f32af8a257cedd47a7070ce8 Mon Sep 17 00:00:00 2001 From: Pushp Date: Fri, 9 Oct 2026 14:37:56 +0530 Subject: [PATCH] feat: add authenticated Mr OneHook context sessions --- README.md | 6 + src/components/Chatbot/ChatbotWidget.tsx | 3 +- src/features/chat/ChatView.tsx | 310 +++++++++++++++++- src/lib/mr-onehook-contract.ts | 289 +++++++++++++++++ src/lib/mr-onehook-excerpts.ts | 192 +++++++++++ src/lib/mr-onehook.ts | 394 ++++++++++++++++++----- src/tests/mr-onehook-contract.test.ts | 184 +++++++++++ src/tests/mr-onehook-excerpts.test.ts | 156 +++++++++ 8 files changed, 1437 insertions(+), 97 deletions(-) create mode 100644 src/lib/mr-onehook-contract.ts create mode 100644 src/lib/mr-onehook-excerpts.ts create mode 100644 src/tests/mr-onehook-contract.test.ts create mode 100644 src/tests/mr-onehook-excerpts.test.ts diff --git a/README.md b/README.md index 5cd8621..c3146b6 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ When the production backend has been deployed and validated, add a source-contro then change only the production build's `VITE_BACKEND_STAGE` selector. Do not switch production URLs independently in pipeline settings. +## Mr OneHook member chat + +The signed-in chat surface uses `POST /api/member/product-connection-chat` with the Cognito **access token**. Members can choose product-only mode or one active match. Match messages remain end-to-end encrypted and are searched only after local decryption; when the service returns `needsMoreContext`, the browser selects bounded readable excerpts, displays the exact excerpt review panel, and sends them only after confirmation. Excerpts carry only `speaker`, `sentAt`, and `text` and are never persisted in the local Mr OneHook transcript. + +Non-FREE tiers request server-side AI-session persistence, while the server remains authoritative. Returned session IDs are stored per user and product/match scope; stale, expired, denied, or unavailable sessions are forgotten and the same question is retried without persistence. The public landing widget remains separate on `POST /api/public/product-chat` without an authorization header. + ## Engineering Policy: No Test Code in Production (strict) **Test, mock, and local-development code must never reach a production artifact, and must not be diff --git a/src/components/Chatbot/ChatbotWidget.tsx b/src/components/Chatbot/ChatbotWidget.tsx index b9dc1fb..426932a 100644 --- a/src/components/Chatbot/ChatbotWidget.tsx +++ b/src/components/Chatbot/ChatbotWidget.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { Mascot } from './Mascot'; import { chatbotUrl } from '../../utils/env.config'; +import { PUBLIC_PRODUCT_CHAT_PATH } from '../../lib/mr-onehook-contract'; import { Maximize2, Minimize2, X } from 'lucide-react'; import './ChatbotWidget.css'; // Add a little standard CSS or use tailwind @@ -160,7 +161,7 @@ export const ChatbotWidget: React.FC = ({ isVisible = true } setMood('Thinking'); try { - const response = await fetch(`${chatbotUrl}/api/chat`, { + const response = await fetch(`${chatbotUrl}${PUBLIC_PRODUCT_CHAT_PATH}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/src/features/chat/ChatView.tsx b/src/features/chat/ChatView.tsx index 241b99f..07449ed 100644 --- a/src/features/chat/ChatView.tsx +++ b/src/features/chat/ChatView.tsx @@ -55,7 +55,18 @@ import { getSortOption, type SortableConversation, } from '../../lib/chat-sort'; -import { useMrOneHookChat, type MrOneHookMessage } from '../../lib/mr-onehook'; +import { + useMrOneHookChat, + type MrOneHookMessage, + type MrOneHookScope, + type PendingExcerptReview, +} from '../../lib/mr-onehook'; +import { + selectEphemeralExcerpts, + toSelectableMessages, + type ExcerptSelectionRequest, +} from '../../lib/mr-onehook-excerpts'; +import { SubscriptionTier } from '../../types'; import { ChatSettingsSheet } from '../../components/chat/ChatSettingsSheet'; import { AttachmentBubble } from '../../components/chat/AttachmentBubble'; import { AttachmentComposer } from '../../components/chat/AttachmentComposer'; @@ -658,7 +669,12 @@ export function ChatView({ {/* ── ACTIVE CONVERSATION (in-place; header + composer are constant) ── */} {isMrOneHook ? ( - + ) : loadingMatches && !activeMatch ? (
@@ -1181,30 +1197,217 @@ function MatchConversation({ } // ───────────────────────────────────────────────────────────────────────────── -// Mr.OneHook conversation — local-only transcript, never touches E2EE APIs. +// Mr.OneHook conversation — authenticated member chat. Local-only transcript; it +// never writes to the E2EE match APIs and is never surfaced as a match. A scope +// is either product-only or exactly ONE authorized match; match scope can ground +// answers in locally-decrypted history, but only after the model asks for context +// and the user explicitly approves the excerpts. // ───────────────────────────────────────────────────────────────────────────── function MrOneHookConversation({ currentUser, wallpaper, + matches, + tier, }: { currentUser: UserProfile; wallpaper: ChatWallpaper; + matches: HydratedMatch[]; + tier: SubscriptionTier; }) { - const demographics = useMemo( - () => ({ - gender: currentUser.gender || 'man', - sexualPreference: (currentUser.interestedIn || []).join(', '), - }), - [currentUser.gender, currentUser.interestedIn] + // Scope the conversation to product-only (default) or a single match the user picks here. + const [scope, setScope] = useState({ type: 'product' }); + const [scopeMenuOpen, setScopeMenuOpen] = useState(false); + + const activeMatch = + scope.type === 'match' ? matches.find((m) => m.matchId === scope.matchId) ?? null : null; + + // If the chosen match disappears (unmatch/block), fall back to product scope. + useEffect(() => { + if (scope.type === 'match' && !matches.some((m) => m.matchId === scope.matchId)) { + setScope({ type: 'product' }); + } + }, [scope, matches]); + + const activeMatchName = + activeMatch?.peerProfile?.displayName || activeMatch?.peerProfile?.name || 'your match'; + + return ( + <> + {/* Scope selector: Product-only, or ground answers in one authorized match's local history. */} +
+
+ + Context + +
+ + + {scopeMenuOpen && ( + <> +
setScopeMenuOpen(false)} /> + + + {matches.length > 0 &&
} + {matches.map((m) => { + const name = m.peerProfile?.displayName || m.peerProfile?.name || 'Your Match'; + const checked = scope.type === 'match' && scope.matchId === m.matchId; + return ( + + ); + })} + + + )} + +
+ {scope.type === 'match' && ( + + Mr.OneHook can reference this chat only after you review & approve excerpts. + + )} +
+
+ + {scope.type === 'match' && activeMatch ? ( + + ) : ( + + )} + ); - const { messages, loading, send } = useMrOneHookChat(currentUser.id, demographics); +} + +/** Product-only Mr.OneHook: no match, no excerpts — the always-available baseline. */ +function MrOneHookProductScope({ + currentUser, + wallpaper, + tier, +}: { + currentUser: UserProfile; + wallpaper: ChatWallpaper; + tier: SubscriptionTier; +}) { + const chat = useMrOneHookChat({ + userId: currentUser.id, + scope: { type: 'product' }, + tier, + }); + return ; +} + +/** + * Match-scoped Mr.OneHook. Reads the match's decrypted messages via the SAME `useChatMessages` hook + * the real conversation uses, and exposes a pure local-search resolver so a `needsMoreContext` turn + * can select bounded excerpts from that already-decrypted history for the user to review. + */ +function MrOneHookMatchScope({ + currentUser, + wallpaper, + match, + matchName, + tier, +}: { + key?: string; + currentUser: UserProfile; + wallpaper: ChatWallpaper; + match: HydratedMatch; + matchName: string; + tier: SubscriptionTier; +}) { + const { messages } = useChatMessages(match.matchId, match.peerId || undefined); + + // Stable snapshot of the decrypted turns for the pure selector; recomputed as history changes. + const selectable = useMemo(() => toSelectableMessages(messages), [messages]); + const selectableRef = useRef(selectable); + selectableRef.current = selectable; + + const resolveLocalExcerpts = useCallback( + (request: ExcerptSelectionRequest) => selectEphemeralExcerpts(selectableRef.current, request), + [], + ); + + const chat = useMrOneHookChat({ + userId: currentUser.id, + scope: { type: 'match', matchId: match.matchId }, + tier, + resolveLocalExcerpts, + }); + + return ; +} + +/** + * Shared presentational body for both scopes: transcript, typing indicator, the inline excerpt + * review panel (shown only when the model asked for more context AND the device found some), and the + * composer. The review panel is the explicit consent gate — nothing is shared until the user approves. + */ +function MrOneHookChatBody({ + chat, + wallpaper, + matchName, +}: { + chat: ReturnType; + wallpaper: ChatWallpaper; + matchName?: string; +}) { + const { messages, loading, mood, pendingReview, send, confirmReview, cancelReview } = chat; const [input, setInput] = useState(''); const endRef = useRef(null); useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, loading]); + }, [messages, loading, pendingReview]); const handleSend = async () => { if (!input.trim()) return; @@ -1249,6 +1452,18 @@ function MrOneHookConversation({
)) )} + + {/* Inline excerpt review — explicit consent before any match message leaves the device. */} + {pendingReview && ( + void confirmReview()} + onCancel={cancelReview} + disabled={loading} + /> + )} + {loading && (
Mr.OneHook
@@ -1267,14 +1482,83 @@ function MrOneHookConversation({ value={input} onChange={setInput} onSend={handleSend} - disabled={loading} - placeholder="Say something to Mr.OneHook…" + disabled={loading || pendingReview !== null} + placeholder={ + pendingReview ? 'Review the excerpts above first…' : 'Say something to Mr.OneHook…' + } footerRight="OneHook AI · Always Here" /> ); } +/** + * The consent gate. Shows the EXACT excerpts that would be shared (speaker, time, text) and sends + * nothing until the user approves. Mirrors the privacy contract: the user sees precisely what leaves + * the device. + */ +function ExcerptReviewPanel({ + review, + matchName, + onConfirm, + onCancel, + disabled, +}: { + review: PendingExcerptReview; + matchName?: string; + onConfirm: () => void; + onCancel: () => void; + disabled?: boolean; +}) { + return ( +
+
+ +

+ Share these {review.excerpts.length} message + {review.excerpts.length === 1 ? '' : 's'} + {matchName ? ` with ${matchName}'s chat` : ''} with Mr.OneHook? +

+
+

+ Only the excerpts below are sent, just for this answer. They’re never stored or logged. +

+
    + {review.excerpts.map((excerpt, i) => ( +
  • +
    + {excerpt.speaker === 'self' ? 'You' : matchName || 'Match'} + {new Date(excerpt.sentAt).toLocaleString()} +
    +

    {excerpt.text}

    +
  • + ))} +
+
+ + +
+
+ ); +} + // ───────────────────────────────────────────────────────────────────────────── // Empty state (matches exist path never triggers this; shown when a match id is // selected but cannot be resolved). diff --git a/src/lib/mr-onehook-contract.ts b/src/lib/mr-onehook-contract.ts new file mode 100644 index 0000000..5def15a --- /dev/null +++ b/src/lib/mr-onehook-contract.ts @@ -0,0 +1,289 @@ +/** + * Mr.OneHook authenticated chat contract. + * + * Typed, framework-free models and (de)serialisers for the OneHook UX Enhancement backend: + * • `POST /api/public/product-chat` — anonymous, product-only (landing widget) + * • `POST /api/member/product-connection-chat` — authenticated member product + connection chat + * • `GET/DELETE .../sessions[/:id]` — tier-gated AI-session lifecycle + * + * Everything here is pure so the request builder and response parser can be unit-tested without a + * network. The builder is also the enforcement point for "never send message IDs/keys": an excerpt + * is reduced to exactly `{ speaker, sentAt, text }` on the way out. + */ + +import type { SelectedExcerpt } from './mr-onehook-excerpts'; + +export type MrOneHookMood = 'Happy' | 'Neutral' | 'Thinking' | 'Sad' | 'Excited'; + +export const MR_ONEHOOK_MOODS: readonly MrOneHookMood[] = [ + 'Happy', + 'Neutral', + 'Thinking', + 'Sad', + 'Excited', +]; + +/** Anonymous product chat used by the public landing widget. */ +export const PUBLIC_PRODUCT_CHAT_PATH = '/api/public/product-chat'; +/** Authenticated member product + connection chat (and its `/sessions` sub-routes). */ +export const MEMBER_CHAT_PATH = '/api/member/product-connection-chat'; + +/** A single rendered turn in the local Mr.OneHook transcript. */ +export interface MrOneHookMessage { + role: 'user' | 'assistant'; + content: string; + /** Transient inline error bubble — styled distinctly and never persisted. */ + isError?: boolean; +} + +export interface MrOneHookDemographics { + gender: string; + sexualPreference: string; +} + +function isMood(value: unknown): value is MrOneHookMood { + return typeof value === 'string' && (MR_ONEHOOK_MOODS as readonly string[]).includes(value); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Request +// ───────────────────────────────────────────────────────────────────────────── + +export interface MemberChatRequestInit { + message: string; + /** Omit for a product-only question. Required to send excerpts or use connection context. */ + matchId?: string; + includeProfile?: boolean; + includeMessageHistory?: boolean; + /** Already selected + user-approved excerpts. Ignored unless a matchId is present. */ + excerpts?: readonly SelectedExcerpt[]; + historyTruncated?: boolean; + userConfirmed?: boolean; + /** The `requestId` echoed back from a prior `needsMoreContext` response. */ + contextRequestId?: string; + /** Request application-readable session persistence (tier-gated server-side). */ + persist?: boolean; + /** Continue an existing session; implies persistence. */ + sessionId?: string; +} + +export interface MemberChatRequestBody { + message: string; + matchId?: string; + contextOptions: { includeProfile: boolean; includeMessageHistory: boolean }; + ephemeralMessageContext?: { + excerpts: SelectedExcerpt[]; + historyTruncated: boolean; + userConfirmed: boolean; + }; + contextRequestId?: string; + sessionOptions?: { persist?: boolean; sessionId?: string }; +} + +/** + * Builds the wire body for the member endpoint. + * + * Guarantees: + * • Excerpts are reduced to `{ speaker, sentAt, text }` only — no IDs, keys, status or receipts. + * • `ephemeralMessageContext` is emitted ONLY with a matchId and message-history enabled, matching + * the server's validation so a product-only request can never carry match excerpts. + * • `sessionOptions` is omitted entirely for a non-persisted request; a sessionId always implies + * `persist: true` (the server rejects a sessionId with persistence disabled). + */ +export function buildMemberChatRequestBody(init: MemberChatRequestInit): MemberChatRequestBody { + const message = init.message.trim(); + if (!message) throw new Error('A message is required'); + + const includeProfile = init.includeProfile !== false; + const includeMessageHistory = init.includeMessageHistory !== false; + + const body: MemberChatRequestBody = { + message, + contextOptions: { includeProfile, includeMessageHistory }, + }; + + if (init.matchId) body.matchId = init.matchId; + + if (init.matchId && includeMessageHistory && init.excerpts && init.excerpts.length > 0) { + body.ephemeralMessageContext = { + // Reduce every excerpt to exactly the three permitted fields — this is the leak guard. + excerpts: init.excerpts.map((excerpt) => ({ + speaker: excerpt.speaker, + sentAt: excerpt.sentAt, + text: excerpt.text, + })), + historyTruncated: init.historyTruncated === true, + userConfirmed: init.userConfirmed === true, + }; + } + + if (init.contextRequestId) body.contextRequestId = init.contextRequestId; + + if (init.sessionId) { + body.sessionOptions = { persist: true, sessionId: init.sessionId }; + } else if (init.persist) { + body.sessionOptions = { persist: true }; + } + + return body; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Response +// ───────────────────────────────────────────────────────────────────────────── + +export interface MemberContextUsed { + profile: boolean; + match: boolean; + messageHistory: boolean; + messageCount: number; + chatSession: boolean; + sessionMessageCount: number; +} + +export interface MemberContextRequest { + requestId: string; + searchTerms: string[]; + dateRange?: { from?: string; to?: string }; + maxMessages: number; +} + +export interface MemberSessionMetadata { + sessionId: string; + updatedAt: string; + expiresAt: string; + messageCount: number; +} + +export interface MemberChatResponse { + reply: string; + mood: MrOneHookMood; + needsMoreContext: boolean; + contextRequest?: MemberContextRequest; + contextUsed?: MemberContextUsed; + requestId?: string; + session?: MemberSessionMetadata; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseContextRequest(value: unknown): MemberContextRequest | undefined { + if (!isRecord(value)) return undefined; + const requestId = typeof value.requestId === 'string' ? value.requestId : ''; + const searchTerms = Array.isArray(value.searchTerms) + ? value.searchTerms.filter((t): t is string => typeof t === 'string') + : []; + const maxMessages = + typeof value.maxMessages === 'number' && Number.isFinite(value.maxMessages) + ? value.maxMessages + : 0; + const request: MemberContextRequest = { requestId, searchTerms, maxMessages }; + if (isRecord(value.dateRange)) { + const from = typeof value.dateRange.from === 'string' ? value.dateRange.from : undefined; + const to = typeof value.dateRange.to === 'string' ? value.dateRange.to : undefined; + if (from || to) request.dateRange = { ...(from ? { from } : {}), ...(to ? { to } : {}) }; + } + return request; +} + +function parseContextUsed(value: unknown): MemberContextUsed | undefined { + if (!isRecord(value)) return undefined; + const bool = (v: unknown): boolean => v === true; + const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0); + return { + profile: bool(value.profile), + match: bool(value.match), + messageHistory: bool(value.messageHistory), + messageCount: num(value.messageCount), + chatSession: bool(value.chatSession), + sessionMessageCount: num(value.sessionMessageCount), + }; +} + +function parseSessionMetadata(value: unknown): MemberSessionMetadata | undefined { + if (!isRecord(value) || typeof value.sessionId !== 'string') return undefined; + return { + sessionId: value.sessionId, + updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '', + expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : '', + messageCount: + typeof value.messageCount === 'number' && Number.isFinite(value.messageCount) + ? value.messageCount + : 0, + }; +} + +/** + * Parses and normalises a member-endpoint JSON body. Tolerant by design — an unexpected shape yields + * safe defaults (empty reply, Neutral mood) rather than throwing, so a malformed response degrades to + * a graceful error bubble instead of crashing the chat. + */ +export function parseMemberChatResponse(data: unknown): MemberChatResponse { + if (!isRecord(data)) { + return { reply: '', mood: 'Neutral', needsMoreContext: false }; + } + const response: MemberChatResponse = { + reply: typeof data.reply === 'string' ? data.reply : '', + mood: isMood(data.mood) ? data.mood : 'Neutral', + needsMoreContext: data.needsMoreContext === true, + }; + if (response.needsMoreContext) { + const contextRequest = parseContextRequest(data.contextRequest); + if (contextRequest) response.contextRequest = contextRequest; + } + const contextUsed = parseContextUsed(data.contextUsed); + if (contextUsed) response.contextUsed = contextUsed; + if (typeof data.requestId === 'string') response.requestId = data.requestId; + const session = parseSessionMetadata(data.session); + if (session) response.session = session; + return response; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Session lifecycle (list / read / delete) +// ───────────────────────────────────────────────────────────────────────────── + +export type SessionScope = 'product' | 'connection'; + +export interface SessionListItem { + sessionId: string; + scope: SessionScope; + createdAt: string; + updatedAt: string; + expiresAt: string; + messageCount: number; +} + +export interface SessionTurn { + role: 'user' | 'assistant'; + content: string; + createdAt: string; +} + +export interface SessionDetail { + sessionId: string; + matchId?: string; + createdAt: string; + updatedAt: string; + expiresAt: string; + messages: SessionTurn[]; +} + +/** + * Server error codes that mean "this session cannot be persisted/continued for this caller". The + * client reacts by forgetting the stored sessionId and retrying the SAME question without + * persistence, so a tier downgrade or a hit limit never blocks getting an answer. + */ +export const SESSION_ERROR_CODES: ReadonlySet = new Set([ + 'CHAT_SESSION_NOT_FOUND', + 'CHAT_SESSION_LIMIT_REACHED', + 'CHAT_SESSION_NOT_INCLUDED_IN_TIER', + 'CHAT_SESSION_STORAGE_NOT_CONFIGURED', + 'CHAT_SESSION_STORAGE_TEMPORARILY_UNAVAILABLE', +]); + +export function isSessionErrorCode(code: unknown): boolean { + return typeof code === 'string' && SESSION_ERROR_CODES.has(code); +} diff --git a/src/lib/mr-onehook-excerpts.ts b/src/lib/mr-onehook-excerpts.ts new file mode 100644 index 0000000..26c7134 --- /dev/null +++ b/src/lib/mr-onehook-excerpts.ts @@ -0,0 +1,192 @@ +/** + * Mr.OneHook ephemeral-excerpt selector. + * + * This module is PURE and side-effect free: given a list of already-decrypted, locally-held match + * messages it chooses a small, bounded set of excerpts to attach to a single authenticated + * `POST /api/member/product-connection-chat` request. It is the only place that decides WHAT leaves + * the device as conversational context, so the privacy guarantees live here and are unit-tested in + * isolation (no React, no network, no storage). + * + * Hard rules enforced here: + * • Only readable, plain-text turns are eligible — undecryptable messages, attachment envelopes, + * failed/sending sends, and empty bodies are excluded and can never become an excerpt. + * • Nothing but `speaker`, `sentAt` and `text` ever appears on an excerpt. Message IDs, ciphertext, + * attachment keys, status and read receipts are intentionally dropped — they must NEVER be sent. + * • Count, per-message length and total length are bounded to mirror the server's limits + * (EPHEMERAL_CONTEXT_MAX_MESSAGES / _MAX_CHARACTERS / CONTEXT_MAX_INPUT_CHARACTERS) so a request + * is never rejected for being too large and the device never over-shares. + */ + +import { isAttachmentFrame } from './chat-attachments'; + +/** + * One already-decrypted candidate turn, normalized to the two facts the selector needs: who spoke + * (from the signed-in user's perspective) and when. `text` is the decrypted plaintext body (the chat + * UI stores decrypted text in a message's `ciphertext` field; see `useChatMessages`). + */ +export interface SelectableMessage { + speaker: 'self' | 'match'; + text: string; + /** Epoch milliseconds. */ + timestamp: number; + /** Set when the envelope could not be decrypted on this device — never eligible. */ + undecryptable?: boolean; + /** MessageStatus wire value (e.g. 'SENT', 'FAILED'); FAILED/SENDING turns are excluded. */ + status?: string; +} + +/** A single excerpt exactly as it is serialised into the request — nothing else is ever added. */ +export interface SelectedExcerpt { + speaker: 'self' | 'match'; + /** ISO-8601 timestamp. */ + sentAt: string; + text: string; +} + +export interface ExcerptSelectionRequest { + /** Terms from the user's question and/or the server's `contextRequest`. Case-insensitive. */ + searchTerms?: readonly string[]; + /** Optional inclusive epoch-ms window (mirrors a server `contextRequest.dateRange`). */ + dateRange?: { from?: number; to?: number }; + /** Upper bound on how many excerpts to return for this request. */ + maxMessages: number; +} + +export interface ExcerptSelectionBounds { + /** Absolute cap on excerpt count regardless of what a request asks for. */ + maxMessages: number; + /** Cap on the summed length of all excerpt bodies. */ + maxTotalCharacters: number; + /** Cap on a single excerpt body; longer bodies are clamped, never dropped silently. */ + maxMessageCharacters: number; +} + +export interface ExcerptSelectionResult { + excerpts: SelectedExcerpt[]; + /** + * True when at least one eligible readable turn was NOT included (count/length bounds or date + * filtering). The server forwards this to the model so it knows the context is partial. + */ + historyTruncated: boolean; +} + +/** Mirrors the backend defaults (EPHEMERAL_CONTEXT_MAX_MESSAGES / _MAX_CHARACTERS / CONTEXT_MAX_INPUT_CHARACTERS). */ +export const DEFAULT_EXCERPT_BOUNDS: ExcerptSelectionBounds = { + maxMessages: 30, + maxTotalCharacters: 12000, + maxMessageCharacters: 4000, +}; + +/** Status wire values that mean "not a settled, readable turn" and are therefore never eligible. */ +const EXCLUDED_STATUSES = new Set(['FAILED', 'SENDING']); + +function isReadableTextTurn(message: SelectableMessage): boolean { + if (message.undecryptable) return false; + if (message.status && EXCLUDED_STATUSES.has(message.status.toUpperCase())) return false; + const text = message.text?.trim(); + if (!text) return false; + // An attachment envelope is framed plaintext, not conversation — it must never be shared as text. + if (isAttachmentFrame(message.text)) return false; + return true; +} + +function withinRange(timestamp: number, range?: ExcerptSelectionRequest['dateRange']): boolean { + if (!range) return true; + if (range.from != null && timestamp < range.from) return false; + if (range.to != null && timestamp > range.to) return false; + return true; +} + +function normalizeTerms(terms?: readonly string[]): string[] { + if (!terms) return []; + return terms + .map((t) => (typeof t === 'string' ? t.trim().toLowerCase() : '')) + .filter((t) => t.length > 0); +} + +function matchesAnyTerm(text: string, terms: readonly string[]): boolean { + if (terms.length === 0) return false; + const haystack = text.toLowerCase(); + return terms.some((term) => haystack.includes(term)); +} + +function clampText(text: string, max: number): string { + return text.length > max ? text.slice(0, max) : text; +} + +/** + * Chooses a bounded, privacy-minimized set of excerpts. + * + * Strategy: keep only readable text turns in range, then prioritise turns that match the search + * terms (most recent first), falling back to the most recent turns so the model always gets some + * grounding even when nothing matches. The final list is returned in chronological order so the + * model reads the exchange the way it happened. + */ +export function selectEphemeralExcerpts( + messages: readonly SelectableMessage[], + request: ExcerptSelectionRequest, + bounds: ExcerptSelectionBounds = DEFAULT_EXCERPT_BOUNDS, +): ExcerptSelectionResult { + const requestedMax = Number.isFinite(request.maxMessages) ? Math.floor(request.maxMessages) : 0; + const effectiveMax = Math.max(0, Math.min(requestedMax, bounds.maxMessages)); + + const eligible = messages.filter(isReadableTextTurn); + const inRange = eligible.filter((m) => withinRange(m.timestamp, request.dateRange)); + const terms = normalizeTerms(request.searchTerms); + + // Priority: relevant turns first, then by recency. Chronological order is restored on output. + const prioritized = [...inRange].sort((a, b) => { + const aRelevant = matchesAnyTerm(a.text, terms); + const bRelevant = matchesAnyTerm(b.text, terms); + if (aRelevant !== bRelevant) return aRelevant ? -1 : 1; + return b.timestamp - a.timestamp; + }); + + const picked: SelectableMessage[] = []; + let totalCharacters = 0; + for (const message of prioritized) { + if (picked.length >= effectiveMax) break; + const text = clampText(message.text.trim(), bounds.maxMessageCharacters); + if (!text) continue; + if (totalCharacters + text.length > bounds.maxTotalCharacters) continue; + picked.push({ ...message, text }); + totalCharacters += text.length; + } + + const excerpts = picked + .sort((a, b) => a.timestamp - b.timestamp) + .map( + (message): SelectedExcerpt => ({ + speaker: message.speaker, + sentAt: new Date(message.timestamp).toISOString(), + text: message.text, + }), + ); + + return { excerpts, historyTruncated: excerpts.length < eligible.length }; +} + +/** + * Adapts the chat UI's decrypted message view (`useChatMessages` output) into selector input. + * + * The chat view normalizes a sender to the literal `'me'` for the signed-in user and stores the + * DECRYPTED plaintext in `ciphertext`. This adapter maps that to the selector's speaker/text/time + * shape and drops everything else so no identifier can leak into an excerpt. + */ +export function toSelectableMessages( + views: readonly { + senderId: string; + ciphertext: string; + timestamp: number; + undecryptable?: boolean; + status?: string; + }[], +): SelectableMessage[] { + return views.map((view) => ({ + speaker: view.senderId === 'me' ? 'self' : 'match', + text: view.ciphertext ?? '', + timestamp: view.timestamp, + undecryptable: view.undecryptable, + status: view.status, + })); +} diff --git a/src/lib/mr-onehook.ts b/src/lib/mr-onehook.ts index 4ca1837..35a7329 100644 --- a/src/lib/mr-onehook.ts +++ b/src/lib/mr-onehook.ts @@ -1,27 +1,36 @@ /** - * Mr.OneHook — the in-app AI conversation. + * Mr.OneHook — the in-app, authenticated AI conversation. * - * This transcript is LOCAL-ONLY. It lives in React state and is persisted per-user to localStorage. - * It MUST NEVER touch the E2EE match/chat APIs and must never be surfaced as a match: Mr.OneHook is - * not a person and has no match record. The only network call is the existing public chatbot - * endpoint (`POST ${chatbotUrl}/api/chat`, no auth header), exactly as `ChatbotWidget` uses it. + * Mr.OneHook is NOT a person and has no match record: this transcript is local-only (React state + + * per-user/per-scope localStorage) and must never be surfaced as a match. The only network call is + * the authenticated member endpoint `POST ${chatbotUrl}/api/member/product-connection-chat`, sent + * with the Amplify Cognito ACCESS token as a Bearer credential. + * + * A conversation has one scope: product-only (no matchId) or exactly one authorized match. Match + * scope can ground answers in locally-decrypted history, but ONLY after the model asks for more + * context (`needsMoreContext`) AND the user explicitly approves the excerpts the device selected. + * Excerpts are never persisted — not in the transcript, not in the session payload, not in logs. + * + * Session persistence is tier-gated: only a non-FREE local display tier requests it, and any + * server-side session denial is handled by transparently retrying the same question without a + * session so the user still gets an answer. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { chatbotUrl } from '../utils/env.config'; +import { SubscriptionTier } from '../types'; +import { + buildMemberChatRequestBody, + isSessionErrorCode, + MEMBER_CHAT_PATH, + parseMemberChatResponse, + type MemberChatResponse, + type MemberSessionMetadata, + type MrOneHookMessage, + type MrOneHookMood, +} from './mr-onehook-contract'; +import type { ExcerptSelectionRequest, ExcerptSelectionResult } from './mr-onehook-excerpts'; -export type MrOneHookMood = 'Happy' | 'Neutral' | 'Thinking' | 'Sad' | 'Excited'; - -export interface MrOneHookMessage { - role: 'user' | 'assistant'; - content: string; - /** Marks the inline error bubble so the UI can style it distinctly and not persist noise. */ - isError?: boolean; -} - -export interface MrOneHookDemographics { - gender: string; - sexualPreference: string; -} +export type { MrOneHookMessage, MrOneHookMood } from './mr-onehook-contract'; // Mirrors ChatbotWidget's failure copy so the AI voice is consistent across surfaces. const SERVER_ERROR_COPY = @@ -29,14 +38,64 @@ const SERVER_ERROR_COPY = const NETWORK_ERROR_COPY = "Hmm, I can't connect right now. Check your connection and try again in a moment."; -function storageKey(userId: string): string { - return `onehook.mrOneHook.transcript.${userId}`; +/** Product-only scope, or a single authorized match. */ +export type MrOneHookScope = { type: 'product' } | { type: 'match'; matchId: string }; + +/** Resolves local, already-decrypted excerpts for a server `contextRequest`. Match scope only. */ +export type LocalExcerptResolver = (request: ExcerptSelectionRequest) => ExcerptSelectionResult; + +/** + * A pending excerpt review. While this is set the UI shows the exact excerpts that WOULD be shared + * and waits for the user to approve (`confirmReview`) or decline (`cancelReview`). No excerpt leaves + * the device until approval. + */ +export interface PendingExcerptReview { + excerpts: ExcerptSelectionResult['excerpts']; + historyTruncated: boolean; + searchTerms: string[]; + /** Carried so the confirmed retry re-asks the original question with the approved excerpts. */ + message: string; + contextRequestId?: string; +} + +export interface UseMrOneHookChatOptions { + userId: string; + scope: MrOneHookScope; + /** Verified local display tier; only a non-FREE tier requests session persistence. */ + tier: SubscriptionTier; + /** Supplied for match scope so a `needsMoreContext` turn can search local history. */ + resolveLocalExcerpts?: LocalExcerptResolver; +} + +export interface UseMrOneHookChat { + messages: MrOneHookMessage[]; + loading: boolean; + hasError: boolean; + mood: MrOneHookMood; + session: MemberSessionMetadata | null; + pendingReview: PendingExcerptReview | null; + send: (text: string) => Promise; + confirmReview: () => Promise; + cancelReview: () => void; + clear: () => void; +} + +function scopeKey(scope: MrOneHookScope): string { + return scope.type === 'match' ? `match:${scope.matchId}` : 'product'; +} + +function transcriptStorageKey(userId: string, scope: MrOneHookScope): string { + return `onehook.mrOneHook.transcript.${userId}.${scopeKey(scope)}`; } -function loadTranscript(userId: string): MrOneHookMessage[] { +function sessionStorageKey(userId: string, scope: MrOneHookScope): string { + return `onehook.mrOneHook.session.${userId}.${scopeKey(scope)}`; +} + +function loadTranscript(userId: string, scope: MrOneHookScope): MrOneHookMessage[] { if (typeof window === 'undefined' || !userId) return []; try { - const raw = window.localStorage.getItem(storageKey(userId)); + const raw = window.localStorage.getItem(transcriptStorageKey(userId, scope)); if (!raw) return []; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? (parsed as MrOneHookMessage[]) : []; @@ -45,105 +104,274 @@ function loadTranscript(userId: string): MrOneHookMessage[] { } } -function saveTranscript(userId: string, messages: MrOneHookMessage[]): void { +function saveTranscript(userId: string, scope: MrOneHookScope, messages: MrOneHookMessage[]): void { if (typeof window === 'undefined' || !userId) return; try { - // Persist only the durable turns; error bubbles are transient UI, not conversation history. + // Persist only durable turns; error bubbles are transient UI, not conversation history. Excerpts + // are never part of a transcript turn, so nothing sensitive is written here. const durable = messages.filter((m) => !m.isError); - window.localStorage.setItem(storageKey(userId), JSON.stringify(durable)); + window.localStorage.setItem(transcriptStorageKey(userId, scope), JSON.stringify(durable)); } catch { /* ignore quota / disabled storage */ } } -export interface UseMrOneHookChat { - messages: MrOneHookMessage[]; - loading: boolean; - hasError: boolean; - mood: MrOneHookMood; - send: (text: string) => Promise; - clear: () => void; +function loadSessionId(userId: string, scope: MrOneHookScope): string | undefined { + if (typeof window === 'undefined' || !userId) return undefined; + try { + return window.localStorage.getItem(sessionStorageKey(userId, scope)) || undefined; + } catch { + return undefined; + } +} + +function persistSessionId(userId: string, scope: MrOneHookScope, sessionId: string | undefined): void { + if (typeof window === 'undefined' || !userId) return; + try { + const key = sessionStorageKey(userId, scope); + if (sessionId) window.localStorage.setItem(key, sessionId); + else window.localStorage.removeItem(key); + } catch { + /* ignore quota / disabled storage */ + } } -export function useMrOneHookChat( - userId: string, - demographics: MrOneHookDemographics -): UseMrOneHookChat { - const [messages, setMessages] = useState(() => loadTranscript(userId)); +/** Reads the Cognito ACCESS token (not the id token) from the current Amplify session. */ +async function fetchAccessToken(): Promise { + try { + const { fetchAuthSession } = await import('aws-amplify/auth'); + const session = await fetchAuthSession(); + return session.tokens?.accessToken?.toString() || null; + } catch { + return null; + } +} + +function toEpochMs(iso?: string): number | undefined { + if (!iso) return undefined; + const value = Date.parse(iso); + return Number.isFinite(value) ? value : undefined; +} + +export function useMrOneHookChat(options: UseMrOneHookChatOptions): UseMrOneHookChat { + const { userId, scope, tier, resolveLocalExcerpts } = options; + const matchId = scope.type === 'match' ? scope.matchId : undefined; + const persistAllowed = tier !== SubscriptionTier.FREE; + const key = scopeKey(scope); + + const [messages, setMessages] = useState(() => loadTranscript(userId, scope)); const [loading, setLoading] = useState(false); const [hasError, setHasError] = useState(false); const [mood, setMood] = useState('Happy'); + const [session, setSession] = useState(null); + const [pendingReview, setPendingReview] = useState(null); + + const sessionIdRef = useRef(loadSessionId(userId, scope)); + const resolveRef = useRef(resolveLocalExcerpts); + resolveRef.current = resolveLocalExcerpts; - // Reload when the signed-in user changes so transcripts never bleed across accounts. + // Reload when the user or scope changes so transcripts/sessions never bleed across accounts/scopes. useEffect(() => { - setMessages(loadTranscript(userId)); + setMessages(loadTranscript(userId, scope)); + sessionIdRef.current = loadSessionId(userId, scope); + setSession(null); + setPendingReview(null); setHasError(false); setMood('Happy'); - }, [userId]); + // scope is reconstructed each render; key is its stable identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, key]); useEffect(() => { - saveTranscript(userId, messages); - }, [userId, messages]); + saveTranscript(userId, scope, messages); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, key, messages]); + + const pushError = useCallback((copy: string) => { + setMessages((prev) => [...prev, { role: 'assistant', content: copy, isError: true }]); + setMood('Sad'); + setHasError(true); + }, []); + + /** + * Posts one member request, transparently retrying WITHOUT a session when the server reports a + * session-level denial (tier not eligible, limit reached, unknown/expired session, storage off). + */ + const postOnce = useCallback( + async (init: { + message: string; + excerpts?: ExcerptSelectionResult['excerpts']; + historyTruncated?: boolean; + userConfirmed?: boolean; + contextRequestId?: string; + }): Promise => { + const token = await fetchAccessToken(); + if (!token) throw new Error('NOT_AUTHENTICATED'); + + const attempt = async (useSession: boolean): Promise => { + const body = buildMemberChatRequestBody({ + message: init.message, + ...(matchId ? { matchId } : {}), + includeProfile: true, + includeMessageHistory: true, + ...(init.excerpts ? { excerpts: init.excerpts } : {}), + ...(init.historyTruncated !== undefined ? { historyTruncated: init.historyTruncated } : {}), + ...(init.userConfirmed !== undefined ? { userConfirmed: init.userConfirmed } : {}), + ...(init.contextRequestId ? { contextRequestId: init.contextRequestId } : {}), + ...(useSession && persistAllowed + ? { persist: true, ...(sessionIdRef.current ? { sessionId: sessionIdRef.current } : {}) } + : {}), + }); + return fetch(`${chatbotUrl}${MEMBER_CHAT_PATH}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }); + }; + + let response = await attempt(persistAllowed); + if (!response.ok && persistAllowed) { + const errorBody = (await response.clone().json().catch(() => ({}))) as { error?: unknown }; + if (isSessionErrorCode(errorBody.error)) { + // The session can't be used — forget it and answer the same question session-free. + sessionIdRef.current = undefined; + persistSessionId(userId, scope, undefined); + setSession(null); + response = await attempt(false); + } + } + + if (!response.ok) throw new Error('SERVER_ERROR'); + return parseMemberChatResponse(await response.json()); + }, + // scope captured via key + matchId; persistAllowed derived from tier. + // eslint-disable-next-line react-hooks/exhaustive-deps + [matchId, persistAllowed, userId, key], + ); + + const applySession = useCallback( + (result: MemberChatResponse) => { + if (result.session) { + sessionIdRef.current = result.session.sessionId; + persistSessionId(userId, scope, result.session.sessionId); + setSession(result.session); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, + [userId, key], + ); + + /** Handles a parsed response: either stage a local excerpt review, or render the final answer. */ + const handleResult = useCallback( + (result: MemberChatResponse, originalMessage: string) => { + applySession(result); + + if (result.needsMoreContext && matchId && resolveRef.current && result.contextRequest) { + const request = result.contextRequest; + const selection = resolveRef.current({ + searchTerms: request.searchTerms, + maxMessages: request.maxMessages, + dateRange: { + from: toEpochMs(request.dateRange?.from), + to: toEpochMs(request.dateRange?.to), + }, + }); + // Surface the model's request-for-context as its turn, then (only if we actually found + // something locally) stage the excerpts for explicit user review before anything is sent. + setMessages((prev) => [...prev, { role: 'assistant', content: result.reply }]); + setMood(result.mood); + if (selection.excerpts.length > 0) { + setPendingReview({ + excerpts: selection.excerpts, + historyTruncated: selection.historyTruncated, + searchTerms: request.searchTerms, + message: originalMessage, + ...(request.requestId ? { contextRequestId: request.requestId } : {}), + }); + } + return; + } + + setMessages((prev) => [...prev, { role: 'assistant', content: result.reply }]); + setMood(result.mood || 'Happy'); + }, + [applySession, matchId], + ); const send = useCallback( async (text: string) => { const trimmed = text.trim(); - if (!trimmed || loading) return; + if (!trimmed || loading || pendingReview) return; const userMessage: MrOneHookMessage = { role: 'user', content: trimmed }; - // Only real conversational turns go to the model (drop any prior error bubbles). - const history = messages.filter((m) => !m.isError); - const outbound = [...history, userMessage]; - setMessages([...messages.filter((m) => !m.isError), userMessage]); + setMessages((prev) => [...prev.filter((m) => !m.isError), userMessage]); setLoading(true); setHasError(false); setMood('Thinking'); try { - const response = await fetch(`${chatbotUrl}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: outbound.map((m) => ({ role: m.role, content: m.content })), - userDemographics: demographics, - }), - }); - - if (response.ok) { - const data = await response.json(); - setMessages((prev) => [ - ...prev, - { role: 'assistant', content: data.reply as string }, - ]); - setMood((data.mood as MrOneHookMood) || 'Happy'); - setHasError(false); - } else { - setMessages((prev) => [ - ...prev, - { role: 'assistant', content: SERVER_ERROR_COPY, isError: true }, - ]); - setMood('Sad'); - setHasError(true); - } - } catch { - setMessages((prev) => [ - ...prev, - { role: 'assistant', content: NETWORK_ERROR_COPY, isError: true }, - ]); - setMood('Sad'); - setHasError(true); + // First pass never carries excerpts — the model must ask for context before we share any. + const result = await postOnce({ message: trimmed }); + handleResult(result, trimmed); + } catch (err) { + pushError(err instanceof Error && err.message === 'NOT_AUTHENTICATED' ? NETWORK_ERROR_COPY : SERVER_ERROR_COPY); } finally { setLoading(false); } }, - [messages, loading, demographics] + [loading, pendingReview, postOnce, handleResult, pushError], ); + const confirmReview = useCallback(async () => { + if (!pendingReview || loading) return; + const review = pendingReview; + setPendingReview(null); + setLoading(true); + setHasError(false); + setMood('Thinking'); + + try { + const result = await postOnce({ + message: review.message, + excerpts: review.excerpts, + historyTruncated: review.historyTruncated, + userConfirmed: true, + ...(review.contextRequestId ? { contextRequestId: review.contextRequestId } : {}), + }); + handleResult(result, review.message); + } catch (err) { + pushError(err instanceof Error && err.message === 'NOT_AUTHENTICATED' ? NETWORK_ERROR_COPY : SERVER_ERROR_COPY); + } finally { + setLoading(false); + } + }, [pendingReview, loading, postOnce, handleResult, pushError]); + + const cancelReview = useCallback(() => { + setPendingReview(null); + }, []); + const clear = useCallback(() => { setMessages([]); + setPendingReview(null); + setSession(null); setHasError(false); setMood('Happy'); - }, []); + // Start a fresh server session on the next message; the prior one lapses per its retention. + sessionIdRef.current = undefined; + persistSessionId(userId, scope, undefined); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, key]); - return { messages, loading, hasError, mood, send, clear }; + return { + messages, + loading, + hasError, + mood, + session, + pendingReview, + send, + confirmReview, + cancelReview, + clear, + }; } diff --git a/src/tests/mr-onehook-contract.test.ts b/src/tests/mr-onehook-contract.test.ts new file mode 100644 index 0000000..60d9ead --- /dev/null +++ b/src/tests/mr-onehook-contract.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; +import { + buildMemberChatRequestBody, + isSessionErrorCode, + MEMBER_CHAT_PATH, + parseMemberChatResponse, + PUBLIC_PRODUCT_CHAT_PATH, +} from '../lib/mr-onehook-contract'; +import type { SelectedExcerpt } from '../lib/mr-onehook-excerpts'; + +/** + * Contract tests for the Mr.OneHook member endpoint. The request builder is also the enforcement + * point for "never send message IDs/keys" and "product-only can never carry excerpts", so those are + * asserted here rather than left to the server to reject. + */ + +const excerpt: SelectedExcerpt = { + speaker: 'match', + sentAt: '2026-10-04T10:00:00.000Z', + text: 'I would really like to visit Japan.', +}; + +describe('endpoint paths', () => { + it('match the OneHook UX Enhancement backend routes', () => { + expect(PUBLIC_PRODUCT_CHAT_PATH).toBe('/api/public/product-chat'); + expect(MEMBER_CHAT_PATH).toBe('/api/member/product-connection-chat'); + }); +}); + +describe('buildMemberChatRequestBody — product-only', () => { + it('omits matchId, excerpts and sessionOptions for a plain product question', () => { + const body = buildMemberChatRequestBody({ message: ' Which features help me connect? ' }); + expect(body).toEqual({ + message: 'Which features help me connect?', + contextOptions: { includeProfile: true, includeMessageHistory: true }, + }); + expect('matchId' in body).toBe(false); + expect('ephemeralMessageContext' in body).toBe(false); + expect('sessionOptions' in body).toBe(false); + }); + + it('never attaches excerpts without a matchId, even if excerpts are supplied', () => { + const body = buildMemberChatRequestBody({ message: 'hi', excerpts: [excerpt] }); + expect('ephemeralMessageContext' in body).toBe(false); + }); + + it('throws on an empty message', () => { + expect(() => buildMemberChatRequestBody({ message: ' ' })).toThrow(); + }); +}); + +describe('buildMemberChatRequestBody — connection + excerpts', () => { + it('attaches reviewed excerpts for an authorized match', () => { + const body = buildMemberChatRequestBody({ + message: 'What did they say about travel?', + matchId: 'match-1', + excerpts: [excerpt], + historyTruncated: true, + userConfirmed: true, + contextRequestId: '33333333-3333-4333-8333-333333333333', + }); + expect(body.matchId).toBe('match-1'); + expect(body.contextRequestId).toBe('33333333-3333-4333-8333-333333333333'); + expect(body.ephemeralMessageContext).toEqual({ + excerpts: [excerpt], + historyTruncated: true, + userConfirmed: true, + }); + }); + + it('reduces each excerpt to exactly speaker/sentAt/text (strips any extra fields)', () => { + const dirty = { + ...excerpt, + messageId: 'should-not-leak', + contentKey: 'secret', + senderId: 'peer-123', + } as unknown as SelectedExcerpt; + const body = buildMemberChatRequestBody({ + message: 'q', + matchId: 'match-1', + excerpts: [dirty], + userConfirmed: true, + }); + const sent = body.ephemeralMessageContext!.excerpts[0]; + expect(Object.keys(sent).sort()).toEqual(['sentAt', 'speaker', 'text']); + expect(JSON.stringify(body)).not.toContain('should-not-leak'); + expect(JSON.stringify(body)).not.toContain('secret'); + }); + + it('suppresses excerpts when message history is disabled', () => { + const body = buildMemberChatRequestBody({ + message: 'q', + matchId: 'match-1', + includeMessageHistory: false, + excerpts: [excerpt], + userConfirmed: true, + }); + expect('ephemeralMessageContext' in body).toBe(false); + expect(body.contextOptions.includeMessageHistory).toBe(false); + }); +}); + +describe('buildMemberChatRequestBody — session options', () => { + it('requests persistence when persist is set', () => { + const body = buildMemberChatRequestBody({ message: 'q', persist: true }); + expect(body.sessionOptions).toEqual({ persist: true }); + }); + + it('always implies persistence when continuing a sessionId', () => { + const body = buildMemberChatRequestBody({ message: 'q', sessionId: 'abc', persist: false }); + expect(body.sessionOptions).toEqual({ persist: true, sessionId: 'abc' }); + }); + + it('omits sessionOptions entirely for a non-persisted request', () => { + const body = buildMemberChatRequestBody({ message: 'q', persist: false }); + expect('sessionOptions' in body).toBe(false); + }); +}); + +describe('parseMemberChatResponse', () => { + it('parses a final answer with contextUsed, requestId and session', () => { + const result = parseMemberChatResponse({ + reply: 'Here you go.', + mood: 'Happy', + contextUsed: { + profile: true, + match: true, + messageHistory: true, + messageCount: 3, + chatSession: true, + sessionMessageCount: 4, + }, + requestId: 'req-1', + session: { + sessionId: 'sess-1', + updatedAt: '2026-10-04T10:00:00.000Z', + expiresAt: '2026-11-03T10:00:00.000Z', + messageCount: 4, + }, + }); + expect(result.reply).toBe('Here you go.'); + expect(result.mood).toBe('Happy'); + expect(result.needsMoreContext).toBe(false); + expect(result.contextUsed?.messageCount).toBe(3); + expect(result.session?.sessionId).toBe('sess-1'); + }); + + it('parses a needsMoreContext response with its bounded contextRequest', () => { + const result = parseMemberChatResponse({ + reply: 'Can you share the travel chat?', + mood: 'Thinking', + needsMoreContext: true, + contextRequest: { + requestId: 'ctx-1', + searchTerms: ['travel', 'japan'], + dateRange: { from: '2026-08-01T00:00:00.000Z', to: '2026-10-04T23:59:59.000Z' }, + maxMessages: 8, + }, + requestId: 'req-2', + }); + expect(result.needsMoreContext).toBe(true); + expect(result.contextRequest).toEqual({ + requestId: 'ctx-1', + searchTerms: ['travel', 'japan'], + dateRange: { from: '2026-08-01T00:00:00.000Z', to: '2026-10-04T23:59:59.000Z' }, + maxMessages: 8, + }); + }); + + it('falls back to safe defaults for an invalid mood or non-object body', () => { + expect(parseMemberChatResponse({ reply: 'x', mood: 'Furious' }).mood).toBe('Neutral'); + expect(parseMemberChatResponse(null)).toEqual({ reply: '', mood: 'Neutral', needsMoreContext: false }); + }); +}); + +describe('isSessionErrorCode', () => { + it('recognises session-level denial codes', () => { + expect(isSessionErrorCode('CHAT_SESSION_NOT_INCLUDED_IN_TIER')).toBe(true); + expect(isSessionErrorCode('CHAT_SESSION_LIMIT_REACHED')).toBe(true); + expect(isSessionErrorCode('CHAT_SESSION_NOT_FOUND')).toBe(true); + expect(isSessionErrorCode('AI_RATE_LIMITED')).toBe(false); + expect(isSessionErrorCode(undefined)).toBe(false); + }); +}); diff --git a/src/tests/mr-onehook-excerpts.test.ts b/src/tests/mr-onehook-excerpts.test.ts new file mode 100644 index 0000000..bb6a04d --- /dev/null +++ b/src/tests/mr-onehook-excerpts.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_EXCERPT_BOUNDS, + selectEphemeralExcerpts, + toSelectableMessages, + type SelectableMessage, +} from '../lib/mr-onehook-excerpts'; + +/** + * The excerpt selector is the single gate that decides what locally-decrypted match history leaves + * the device. These tests pin the privacy-critical behaviour: exclusion rules, hard bounds, and that + * only speaker/sentAt/text ever appear on an excerpt. + */ + +const BASE = Date.parse('2026-10-01T00:00:00.000Z'); +const minutes = (n: number) => BASE + n * 60_000; + +function msg(overrides: Partial & { text: string; timestamp: number }): SelectableMessage { + return { speaker: 'match', status: 'SENT', ...overrides }; +} + +describe('selectEphemeralExcerpts — eligibility', () => { + it('excludes undecryptable, attachment, empty, and failed/sending turns', () => { + const attachmentFrame = `${String.fromCharCode(1)}{"v":1,"kind":"image"}`; + const messages: SelectableMessage[] = [ + msg({ text: 'readable one', timestamp: minutes(1) }), + msg({ text: '', timestamp: minutes(2) }), + msg({ text: ' ', timestamp: minutes(3) }), + msg({ text: 'cannot read', timestamp: minutes(4), undecryptable: true }), + msg({ text: attachmentFrame, timestamp: minutes(5) }), + msg({ text: 'failed send', timestamp: minutes(6), status: 'FAILED' }), + msg({ text: 'still sending', timestamp: minutes(7), status: 'SENDING' }), + msg({ text: 'readable two', timestamp: minutes(8) }), + ]; + + const result = selectEphemeralExcerpts(messages, { maxMessages: 30 }); + + expect(result.excerpts.map((e) => e.text)).toEqual(['readable one', 'readable two']); + expect(result.historyTruncated).toBe(false); + }); + + it('emits ONLY speaker, sentAt and text — never ids/status/keys', () => { + const result = selectEphemeralExcerpts( + [msg({ speaker: 'self', text: 'hello there', timestamp: minutes(1), status: 'READ' })], + { maxMessages: 30 }, + ); + expect(result.excerpts).toHaveLength(1); + expect(Object.keys(result.excerpts[0]).sort()).toEqual(['sentAt', 'speaker', 'text']); + expect(result.excerpts[0]).toEqual({ + speaker: 'self', + sentAt: new Date(minutes(1)).toISOString(), + text: 'hello there', + }); + }); +}); + +describe('selectEphemeralExcerpts — bounds', () => { + it('caps the excerpt count at the requested maximum', () => { + const messages = Array.from({ length: 10 }, (_, i) => + msg({ text: `message ${i}`, timestamp: minutes(i) }), + ); + const result = selectEphemeralExcerpts(messages, { maxMessages: 3 }); + expect(result.excerpts).toHaveLength(3); + expect(result.historyTruncated).toBe(true); + }); + + it('never exceeds the absolute count bound even if the request asks for more', () => { + const messages = Array.from({ length: 40 }, (_, i) => + msg({ text: `m${i}`, timestamp: minutes(i) }), + ); + const result = selectEphemeralExcerpts(messages, { maxMessages: 1000 }); + expect(result.excerpts.length).toBeLessThanOrEqual(DEFAULT_EXCERPT_BOUNDS.maxMessages); + }); + + it('clamps an over-long body to the per-message character bound', () => { + const long = 'x'.repeat(DEFAULT_EXCERPT_BOUNDS.maxMessageCharacters + 500); + const result = selectEphemeralExcerpts([msg({ text: long, timestamp: minutes(1) })], { + maxMessages: 30, + }); + expect(result.excerpts[0].text).toHaveLength(DEFAULT_EXCERPT_BOUNDS.maxMessageCharacters); + }); + + it('respects the total-character budget', () => { + const chunk = 'y'.repeat(1000); + const messages = Array.from({ length: 20 }, (_, i) => + msg({ text: chunk, timestamp: minutes(i) }), + ); + const result = selectEphemeralExcerpts(messages, { maxMessages: 30 }, { + maxMessages: 30, + maxTotalCharacters: 3500, + maxMessageCharacters: 4000, + }); + const total = result.excerpts.reduce((sum, e) => sum + e.text.length, 0); + expect(total).toBeLessThanOrEqual(3500); + expect(result.excerpts.length).toBe(3); + }); +}); + +describe('selectEphemeralExcerpts — relevance, dates, ordering', () => { + it('prioritises turns matching the search terms, output stays chronological', () => { + const messages: SelectableMessage[] = [ + msg({ text: 'we talked about the weather', timestamp: minutes(1) }), + msg({ text: 'I would love to visit Japan someday', timestamp: minutes(2) }), + msg({ text: 'what did you have for lunch', timestamp: minutes(3) }), + msg({ text: 'Japan has amazing trains', timestamp: minutes(4) }), + ]; + const result = selectEphemeralExcerpts(messages, { searchTerms: ['japan'], maxMessages: 2 }); + expect(result.excerpts.map((e) => e.text)).toEqual([ + 'I would love to visit Japan someday', + 'Japan has amazing trains', + ]); + }); + + it('falls back to most recent turns when nothing matches', () => { + const messages = Array.from({ length: 5 }, (_, i) => + msg({ text: `turn ${i}`, timestamp: minutes(i) }), + ); + const result = selectEphemeralExcerpts(messages, { searchTerms: ['nope'], maxMessages: 2 }); + expect(result.excerpts.map((e) => e.text)).toEqual(['turn 3', 'turn 4']); + }); + + it('filters by inclusive date range', () => { + const messages = Array.from({ length: 5 }, (_, i) => + msg({ text: `d${i}`, timestamp: minutes(i) }), + ); + const result = selectEphemeralExcerpts(messages, { + maxMessages: 30, + dateRange: { from: minutes(1), to: minutes(3) }, + }); + expect(result.excerpts.map((e) => e.text)).toEqual(['d1', 'd2', 'd3']); + }); + + it('reports historyTruncated=false only when every eligible turn is included', () => { + const messages = [ + msg({ text: 'a', timestamp: minutes(1) }), + msg({ text: 'b', timestamp: minutes(2) }), + ]; + expect(selectEphemeralExcerpts(messages, { maxMessages: 30 }).historyTruncated).toBe(false); + expect(selectEphemeralExcerpts(messages, { maxMessages: 1 }).historyTruncated).toBe(true); + }); +}); + +describe('toSelectableMessages', () => { + it("maps the signed-in user's 'me' sender to self and others to match", () => { + const result = toSelectableMessages([ + { senderId: 'me', ciphertext: 'mine', timestamp: minutes(1), status: 'SENT' }, + { senderId: 'peer-123', ciphertext: 'theirs', timestamp: minutes(2), status: 'READ' }, + { senderId: 'peer-123', ciphertext: '', timestamp: minutes(3), undecryptable: true }, + ]); + expect(result).toEqual([ + { speaker: 'self', text: 'mine', timestamp: minutes(1), status: 'SENT', undecryptable: undefined }, + { speaker: 'match', text: 'theirs', timestamp: minutes(2), status: 'READ', undecryptable: undefined }, + { speaker: 'match', text: '', timestamp: minutes(3), status: undefined, undecryptable: true }, + ]); + }); +});