|
| 1 | +/** |
| 2 | + * Fake sandbox sidecar for the vertical composition suite: ONE in-memory |
| 3 | + * interaction registry that BOTH halves of `/interactions` talk to — |
| 4 | + * |
| 5 | + * - the producer half: the fake sandbox turn registers an ask via `ask()` |
| 6 | + * and blocks on the returned promise (the broker semantics the real |
| 7 | + * sidecar's InteractionBroker has); |
| 8 | + * - the answer half: `createInteractionAnswerRoute` reaches the same |
| 9 | + * registry through `connection.fetchImpl` (GET list / POST respond), so |
| 10 | + * answering through the route genuinely releases the blocked turn. |
| 11 | + * |
| 12 | + * Answer validation is fail-closed like the real sidecar: an `accepted` |
| 13 | + * outcome must satisfy the ask's `answerSpec` (a select without `allowCustom` |
| 14 | + * only accepts listed option values; required fields must be present) or the |
| 15 | + * POST fails 400 INVALID_INTERACTION_ANSWER. Unknown ids fail 404. |
| 16 | + */ |
| 17 | + |
| 18 | +import type { |
| 19 | + ChatSelectField, |
| 20 | + InteractionData, |
| 21 | + InteractionRequestWire, |
| 22 | + SidecarInteractionsConnection, |
| 23 | +} from '../../src/interactions/index' |
| 24 | + |
| 25 | +export interface InteractionResolutionRecord { |
| 26 | + outcome: 'accepted' | 'declined' |
| 27 | + data?: InteractionData |
| 28 | +} |
| 29 | + |
| 30 | +interface Waiter { |
| 31 | + resolve: (resolution: InteractionResolutionRecord) => void |
| 32 | + promise: Promise<InteractionResolutionRecord> |
| 33 | +} |
| 34 | + |
| 35 | +function validateAcceptedAnswer(request: InteractionRequestWire, data: InteractionData | undefined): string | null { |
| 36 | + for (const field of request.answerSpec.fields) { |
| 37 | + const value = data?.[field.name] |
| 38 | + if (value === undefined) { |
| 39 | + if (field.required) return `missing required field ${field.name}` |
| 40 | + continue |
| 41 | + } |
| 42 | + if (field.type === 'select') { |
| 43 | + const select = field as ChatSelectField |
| 44 | + if (select.allowCustom === true) continue |
| 45 | + const values = Array.isArray(value) ? value : [String(value)] |
| 46 | + const allowed = new Set(select.options.map((option) => option.value)) |
| 47 | + for (const candidate of values) { |
| 48 | + if (!allowed.has(String(candidate))) return `value ${String(candidate)} is not a listed option for ${field.name}` |
| 49 | + } |
| 50 | + } |
| 51 | + } |
| 52 | + return null |
| 53 | +} |
| 54 | + |
| 55 | +export interface FakeSidecarSession { |
| 56 | + /** Producer half: register an ask and get the broker promise the turn |
| 57 | + * blocks on. */ |
| 58 | + ask(request: InteractionRequestWire): Promise<InteractionResolutionRecord> |
| 59 | + /** Resolves when every currently-outstanding ask has been answered. */ |
| 60 | + waitAll(): Promise<Map<string, InteractionResolutionRecord>> |
| 61 | + /** Asks still waiting for an answer (the sidecar registry view). */ |
| 62 | + outstandingIds(): string[] |
| 63 | + /** How the answer route reaches this session. */ |
| 64 | + connection: SidecarInteractionsConnection |
| 65 | + /** Every HTTP call the answer route made, for hostile-integrator asserts. */ |
| 66 | + calls: Array<{ method: string; body?: Record<string, unknown> }> |
| 67 | +} |
| 68 | + |
| 69 | +export function createFakeSidecarSession(sessionId = 'session-vertical'): FakeSidecarSession { |
| 70 | + const outstanding = new Map<string, InteractionRequestWire>() |
| 71 | + const waiters = new Map<string, Waiter>() |
| 72 | + const resolutions = new Map<string, InteractionResolutionRecord>() |
| 73 | + const calls: FakeSidecarSession['calls'] = [] |
| 74 | + |
| 75 | + function ask(request: InteractionRequestWire): Promise<InteractionResolutionRecord> { |
| 76 | + outstanding.set(request.id, request) |
| 77 | + let resolve!: Waiter['resolve'] |
| 78 | + const promise = new Promise<InteractionResolutionRecord>((res) => { |
| 79 | + resolve = res |
| 80 | + }) |
| 81 | + waiters.set(request.id, { resolve, promise }) |
| 82 | + return promise |
| 83 | + } |
| 84 | + |
| 85 | + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { |
| 86 | + const method = init?.method ?? 'GET' |
| 87 | + const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : undefined |
| 88 | + calls.push({ method, ...(body ? { body } : {}) }) |
| 89 | + void input |
| 90 | + |
| 91 | + if (method === 'GET') { |
| 92 | + return Response.json({ data: { interactions: [...outstanding.values()] } }) |
| 93 | + } |
| 94 | + |
| 95 | + const id = String(body?.id ?? '') |
| 96 | + const request = outstanding.get(id) |
| 97 | + if (!request) { |
| 98 | + return new Response( |
| 99 | + JSON.stringify({ error: { code: 'NOT_FOUND', message: 'interaction not found' } }), |
| 100 | + { status: 404 }, |
| 101 | + ) |
| 102 | + } |
| 103 | + const outcome = body?.outcome === 'declined' ? 'declined' : 'accepted' |
| 104 | + const data = body?.data as InteractionData | undefined |
| 105 | + if (outcome === 'accepted') { |
| 106 | + const invalid = validateAcceptedAnswer(request, data) |
| 107 | + if (invalid) { |
| 108 | + return new Response( |
| 109 | + JSON.stringify({ error: { code: 'INVALID_INTERACTION_ANSWER', message: invalid } }), |
| 110 | + { status: 400 }, |
| 111 | + ) |
| 112 | + } |
| 113 | + } |
| 114 | + outstanding.delete(id) |
| 115 | + const resolution: InteractionResolutionRecord = { outcome, ...(data ? { data } : {}) } |
| 116 | + resolutions.set(id, resolution) |
| 117 | + waiters.get(id)?.resolve(resolution) |
| 118 | + waiters.delete(id) |
| 119 | + return Response.json({ data: { ok: true } }) |
| 120 | + }) as typeof fetch |
| 121 | + |
| 122 | + return { |
| 123 | + ask, |
| 124 | + async waitAll() { |
| 125 | + await Promise.all([...waiters.values()].map((waiter) => waiter.promise)) |
| 126 | + return new Map(resolutions) |
| 127 | + }, |
| 128 | + outstandingIds: () => [...outstanding.keys()], |
| 129 | + connection: { |
| 130 | + runtimeUrl: 'https://box.vertical.test/runtime', |
| 131 | + authToken: 'tc_vertical-sidecar-bearer', |
| 132 | + sessionId, |
| 133 | + fetchImpl, |
| 134 | + }, |
| 135 | + calls, |
| 136 | + } |
| 137 | +} |
0 commit comments