diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b063505..751a5bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,9 @@ jobs: env: # tsup generates .d.ts for ~38 entries in a worker thread (the `prepare` # script runs during install, and again in the build step); that worker - # needs ~4.7GB and OOMs at the default limit. The worker inherits + # needed ~4.7GB and has grown with the subpath count (OOMed at 6144 after the #191/#192 merges); 8192 matches the documented local build requirement. The worker inherits # NODE_OPTIONS, so set the heap once at the job level. - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9a0fcf6..7a03bc3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,7 +34,7 @@ jobs: contents: write # push the version-bump commit + tag id-token: write # OIDC trusted publish + Sigstore provenance env: - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 steps: - uses: actions/checkout@v7 with: @@ -93,7 +93,7 @@ jobs: # ERR_WORKER_OUT_OF_MEMORY on the hosted runner. The worker inherits # NODE_OPTIONS, so a job-level heap covers install (prepare), test, and # build in one place. - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 @@ -129,7 +129,7 @@ jobs: env: # Same DTS-worker heap need: this job runs `pnpm install` (prepare → tsup) # and `pnpm run build` before publishing. - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 permissions: contents: read id-token: write diff --git a/src/chat-store/parts.ts b/src/chat-store/parts.ts index e780796..fe8dc01 100644 --- a/src/chat-store/parts.ts +++ b/src/chat-store/parts.ts @@ -44,7 +44,9 @@ import type { Part as HarnessWirePart } from '@tangle-network/agent-interface' import type { ChatInteractionField, ChatInteractionStatus, + InteractionPersistedPart, NoticeKind, + NoticePersistedPart, } from '../web-react/chat-interactions' /** Start/end wall-clock millis, as normalized by `/stream`'s `normalizeTime`. */ @@ -168,6 +170,15 @@ export interface ChatNoticePart { text: string } +// The "byte-matches" claims above, enforced at compile time: the interaction +// contract's codec output types and the stored part types must stay mutually +// assignable, so a codec field added on one side without the other fails here. +type MutuallyAssignable = A +type _CodecEmitsStorableInteractionPart = MutuallyAssignable +type _StoredInteractionPartFeedsCodec = MutuallyAssignable +type _CodecEmitsStorableNoticePart = MutuallyAssignable +type _StoredNoticePartFeedsCodec = MutuallyAssignable + export type ChatMessagePart = | ChatTextPart | ChatReasoningPart diff --git a/src/chat-store/store.ts b/src/chat-store/store.ts index ba4e9ef..5c1a5b3 100644 --- a/src/chat-store/store.ts +++ b/src/chat-store/store.ts @@ -230,7 +230,9 @@ export function createChatStore( // Access is verified once per workspace the ids touch. Fail-closed: one // inaccessible workspace rejects the whole request before any delete. - const workspaceIds = [...new Set(rows.map((row) => row.workspaceId))] + // Sorted so the check order (and therefore which denial surfaces) is + // deterministic — row order follows random hex ids and varies per run. + const workspaceIds = [...new Set(rows.map((row) => row.workspaceId))].sort() for (const workspaceId of workspaceIds) { await assertAccess(workspaceId) } diff --git a/src/interactions/contract.ts b/src/interactions/contract.ts index 521bd55..3f24038 100644 --- a/src/interactions/contract.ts +++ b/src/interactions/contract.ts @@ -226,9 +226,35 @@ export function noticePartKey(id: string): string { export type NoticeKind = 'warning' | 'auto-declined' +/** + * Persisted-part shapes the codecs below produce — the SAME rows + * `/chat-store`'s `ChatInteractionPart`/`ChatNoticePart` store, typed at the + * source so a product pushing them into a `ChatMessagePart[]` transcript needs + * no cast. Type aliases (not interfaces) on purpose: the implicit index + * signature keeps them assignable to the `Record` these + * codecs previously returned, so existing consumers stay source-compatible. + */ +export type InteractionPersistedPart = { + type: 'interaction' + id: string + kind: string + title: string + body?: string + answerSpec: { fields: ChatInteractionField[] } + status: ChatInteractionStatus + cancelReason?: string +} + +export type NoticePersistedPart = { + type: 'notice' + id: string + noticeKind: NoticeKind + text: string +} + /** Builds the persisted/streamed `notice` part — a one-line transcript notice * explaining an out-of-band event (warning, auto-declined interaction). */ -export function noticePart(noticeKind: NoticeKind, id: string, text: string): Record { +export function noticePart(noticeKind: NoticeKind, id: string, text: string): NoticePersistedPart { return { type: 'notice', id, noticeKind, text } } @@ -249,7 +275,7 @@ export function interactionToPersistedPart( request: InteractionRequestWire, status: ChatInteractionStatus, cancelReason?: string, -): Record { +): InteractionPersistedPart { return { type: 'interaction', id: request.id, diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts index 2b2382b..0bec651 100644 --- a/src/sandbox/index.ts +++ b/src/sandbox/index.ts @@ -1035,12 +1035,22 @@ function utf8ByteLength(value: unknown): number { .byteLength } +/** Structural slice of the profile the payload gate reads: it only measures + * the profile's serialized size and names `resources.files` in the breakdown, + * so callers composing a payload outside the SDK (products, tests) can pass a + * plain object without casting through `AgentProfile`. */ +export interface ProvisionProfileSection { + resources?: { files?: readonly unknown[] } +} + /** The provision-payload sections the size gates need to see. Structural so * the gate is testable without the SDK's (unexported) create-payload type. */ export interface ProvisionPayloadSections { env?: Record secrets?: readonly string[] - backend?: { profile?: AgentProfile } + /** `profile` may also be a named-profile string ref (the SDK's + * `BackendConfig` union) — a string ref is tiny and has no files channel. */ + backend?: { profile?: string | ProvisionProfileSection } } /** @@ -1053,7 +1063,7 @@ export function assertProvisionPayloadWithinCap(payload: ProvisionPayloadSection const total = utf8ByteLength(payload) if (total <= PROVISION_PAYLOAD_MAX_BYTES) return const profile = payload.backend?.profile - const files = profile?.resources?.files ?? [] + const files = (typeof profile === 'string' ? undefined : profile?.resources?.files) ?? [] const breakdown = `profile=${utf8ByteLength(profile ?? null)}B ` + `(files=${utf8ByteLength(files)}B), ` + @@ -1549,7 +1559,9 @@ export async function ensureWorkspaceSandbox( // provision body can never produce a working sandbox — fail loud here, // before the POST, with the offending section named. assertEnvWithinLimits(env) - assertProvisionPayloadWithinCap(payload as ProvisionPayloadSections) + // `?? {}` only narrows the SDK parameter's `| undefined`; the literal above + // is always defined. The structural sections type needs no cast. + assertProvisionPayloadWithinCap(payload ?? {}) let box = await client.create(payload) diff --git a/tests/vertical/fake-sidecar.ts b/tests/vertical/fake-sidecar.ts new file mode 100644 index 0000000..9b70497 --- /dev/null +++ b/tests/vertical/fake-sidecar.ts @@ -0,0 +1,137 @@ +/** + * Fake sandbox sidecar for the vertical composition suite: ONE in-memory + * interaction registry that BOTH halves of `/interactions` talk to — + * + * - the producer half: the fake sandbox turn registers an ask via `ask()` + * and blocks on the returned promise (the broker semantics the real + * sidecar's InteractionBroker has); + * - the answer half: `createInteractionAnswerRoute` reaches the same + * registry through `connection.fetchImpl` (GET list / POST respond), so + * answering through the route genuinely releases the blocked turn. + * + * Answer validation is fail-closed like the real sidecar: an `accepted` + * outcome must satisfy the ask's `answerSpec` (a select without `allowCustom` + * only accepts listed option values; required fields must be present) or the + * POST fails 400 INVALID_INTERACTION_ANSWER. Unknown ids fail 404. + */ + +import type { + ChatSelectField, + InteractionData, + InteractionRequestWire, + SidecarInteractionsConnection, +} from '../../src/interactions/index' + +export interface InteractionResolutionRecord { + outcome: 'accepted' | 'declined' + data?: InteractionData +} + +interface Waiter { + resolve: (resolution: InteractionResolutionRecord) => void + promise: Promise +} + +function validateAcceptedAnswer(request: InteractionRequestWire, data: InteractionData | undefined): string | null { + for (const field of request.answerSpec.fields) { + const value = data?.[field.name] + if (value === undefined) { + if (field.required) return `missing required field ${field.name}` + continue + } + if (field.type === 'select') { + const select = field as ChatSelectField + if (select.allowCustom === true) continue + const values = Array.isArray(value) ? value : [String(value)] + const allowed = new Set(select.options.map((option) => option.value)) + for (const candidate of values) { + if (!allowed.has(String(candidate))) return `value ${String(candidate)} is not a listed option for ${field.name}` + } + } + } + return null +} + +export interface FakeSidecarSession { + /** Producer half: register an ask and get the broker promise the turn + * blocks on. */ + ask(request: InteractionRequestWire): Promise + /** Resolves when every currently-outstanding ask has been answered. */ + waitAll(): Promise> + /** Asks still waiting for an answer (the sidecar registry view). */ + outstandingIds(): string[] + /** How the answer route reaches this session. */ + connection: SidecarInteractionsConnection + /** Every HTTP call the answer route made, for hostile-integrator asserts. */ + calls: Array<{ method: string; body?: Record }> +} + +export function createFakeSidecarSession(sessionId = 'session-vertical'): FakeSidecarSession { + const outstanding = new Map() + const waiters = new Map() + const resolutions = new Map() + const calls: FakeSidecarSession['calls'] = [] + + function ask(request: InteractionRequestWire): Promise { + outstanding.set(request.id, request) + let resolve!: Waiter['resolve'] + const promise = new Promise((res) => { + resolve = res + }) + waiters.set(request.id, { resolve, promise }) + return promise + } + + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? 'GET' + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : undefined + calls.push({ method, ...(body ? { body } : {}) }) + void input + + if (method === 'GET') { + return Response.json({ data: { interactions: [...outstanding.values()] } }) + } + + const id = String(body?.id ?? '') + const request = outstanding.get(id) + if (!request) { + return new Response( + JSON.stringify({ error: { code: 'NOT_FOUND', message: 'interaction not found' } }), + { status: 404 }, + ) + } + const outcome = body?.outcome === 'declined' ? 'declined' : 'accepted' + const data = body?.data as InteractionData | undefined + if (outcome === 'accepted') { + const invalid = validateAcceptedAnswer(request, data) + if (invalid) { + return new Response( + JSON.stringify({ error: { code: 'INVALID_INTERACTION_ANSWER', message: invalid } }), + { status: 400 }, + ) + } + } + outstanding.delete(id) + const resolution: InteractionResolutionRecord = { outcome, ...(data ? { data } : {}) } + resolutions.set(id, resolution) + waiters.get(id)?.resolve(resolution) + waiters.delete(id) + return Response.json({ data: { ok: true } }) + }) as typeof fetch + + return { + ask, + async waitAll() { + await Promise.all([...waiters.values()].map((waiter) => waiter.promise)) + return new Map(resolutions) + }, + outstandingIds: () => [...outstanding.keys()], + connection: { + runtimeUrl: 'https://box.vertical.test/runtime', + authToken: 'tc_vertical-sidecar-bearer', + sessionId, + fetchImpl, + }, + calls, + } +} diff --git a/tests/vertical/mini-app.ts b/tests/vertical/mini-app.ts new file mode 100644 index 0000000..119e1ad --- /dev/null +++ b/tests/vertical/mini-app.ts @@ -0,0 +1,449 @@ +/** + * The assembled mini chat app for the vertical composition suite — a product + * built ONLY from the shipped subpaths, wired the way a real consumer wires + * them (no test doubles between the modules themselves): + * + * auth `createAppAuth` (better-auth memoryAdapter) + its guard quartet + * storage `createChatTables` + `createChatStore` over :memory: better-sqlite3 + * gates `assertSystemPromptWithinBudget` + `assertEnvWithinLimits` + + * `assertProvisionPayloadWithinCap` at the turn boundary (#190) + * turn a fake sandbox producer emitting canonical sidecar part events + * (`message.part.updated` snapshots + deltas, step-finish usage + * receipts, interaction asks, error) pumped through `/stream`'s + * normalizer into BOTH lanes: the NDJSON client stream + * (`/web-react` chat-stream line shapes) and the persisted + * assistant message parts + * asks `/interactions`: the producer blocks on the fake sidecar broker; + * `createInteractionAnswerRoute` answers through the same registry + * + * Access control composes app-auth with the store's injected seams: guards + * throw Responses (the router convention), the product membership check turns + * a session into per-workspace allow/deny, and `bulkDeleteThreads` receives it + * as `assertAccess`. + */ + +import { + interactionToPersistedPart, + noticePart, + createInteractionAnswerRoute, + type ChatInteractionStatus, + type InteractionRequestWire, + type InteractionAnswerRoute, +} from '../../src/interactions/index' +import { createAppAuth, type AppAuth, type AppAuthSession } from '../../src/app-auth/index' +import { memoryAdapter } from 'better-auth/adapters/memory' +import { createChatTables, type ChatTables } from '../../src/chat-store/schema' +import { createChatStore, type ChatDatabase, type ChatStore } from '../../src/chat-store/store' +import { ChatStoreInputError, type ChatMessagePart } from '../../src/chat-store/index' +import { + finalizeAssistantParts, + getPartKey, + mergePersistedPart, + normalizePersistedPart, + type JsonRecord, +} from '../../src/stream/index' +import { assertEnvWithinLimits, assertProvisionPayloadWithinCap } from '../../src/sandbox/index' +import { assertSystemPromptWithinBudget, type ComposeProfileBudget } from '../../src/profile/index' +import { openDatabase, workspacesTable } from '../teams/db-helper' +import { createFakeSidecarSession, type FakeSidecarSession, type InteractionResolutionRecord } from './fake-sidecar' + +export const MINI_APP_MODEL = 'mini/model-1' +const BASE_URL = 'http://localhost:3000' +const AUTH_SECRET = 'vertical-suite-secret' + +// --------------------------------------------------------------------------- +// Fake sandbox producer vocabulary — canonical sidecar shapes (mirrors the +// `message.part.updated` envelope the ADC sidecar emits, minus the +// session/message ids the normalizer strips anyway). + +export type ProducerEvent = + /** A canonical part snapshot; `delta` is the increment for text/reasoning. */ + | { type: 'message.part.updated'; part: JsonRecord; delta?: string } + /** An ask. `block: false` registers + emits without waiting (a re-emitted + * duplicate); the default blocks the turn until EVERY outstanding ask is + * resolved — the broker semantics. */ + | { type: 'interaction'; request: InteractionRequestWire; block?: boolean } + /** The turn failed server-side. */ + | { type: 'error'; message: string } + +interface MiniAppOptions { + /** The scripted turn every chat POST runs. */ + script?: ProducerEvent[] + /** #190 gate knobs. */ + budget?: ComposeProfileBudget + sandboxEnv?: Record + /** Extra bytes staged into the provision profile (files channel). */ + profileFileContent?: string + /** Product prompt composition; default is a small static prompt. */ + systemPromptFor?: (message: string) => string +} + +export interface MiniApp { + appAuth: AppAuth + store: ChatStore + tables: ChatTables + db: ChatDatabase + sidecar: FakeSidecarSession + routes: { + /** POST `{ workspaceId, threadId?, message }` → NDJSON turn stream. */ + chat(request: Request): Promise + /** GET `?workspaceId=` → `{ threads, total }`. */ + listThreads(request: Request): Promise + /** GET `?threadId=` → `{ thread, messages }`; inaccessible reads 404. */ + getThread(request: Request): Promise + /** POST `{ ids }` → `{ deleted }`; any denied workspace rejects all. */ + bulkDeleteThreads(request: Request): Promise + interactions: InteractionAnswerRoute + } + /** Sign up a user and return the Cookie header a browser would replay. */ + signUp(email: string): Promise + grantMembership(email: string, workspaceId: string): void + createWorkspace(id: string, name?: string): Promise +} + +function deniedWorkspaceResponse(): Response { + return Response.json({ error: 'Forbidden', code: 'workspace.forbidden' }, { status: 403 }) +} + +/** Route-boundary convention: guards and access checks THROW Responses. */ +async function handle(fn: () => Promise): Promise { + try { + return await fn() + } catch (err) { + if (err instanceof Response) return err + if (err instanceof ChatStoreInputError) { + return Response.json({ error: err.message }, { status: 400 }) + } + throw err + } +} + +export async function createMiniApp(options: MiniAppOptions = {}): Promise { + const tables = createChatTables({ workspaceTable: workspacesTable }) + const db = openDatabase([workspacesTable, tables.threads, tables.messages]) as unknown as ChatDatabase + const store = createChatStore(db, tables) + const sidecar = createFakeSidecarSession() + + const appAuth = createAppAuth({ + appName: 'Vertical Mini App', + baseURL: BASE_URL, + secret: AUTH_SECRET, + database: memoryAdapter({ user: [], session: [], account: [], verification: [] }), + }) + + const memberships = new Map>() + const isMember = (email: string, workspaceId: string) => memberships.get(email)?.has(workspaceId) ?? false + const assertMember = (session: AppAuthSession, workspaceId: string) => { + if (!isMember(session.user.email, workspaceId)) throw deniedWorkspaceResponse() + } + + const systemPromptFor = options.systemPromptFor ?? (() => 'You are the vertical mini app agent.') + let turnCounter = 0 + + function runGates(message: string): void { + assertSystemPromptWithinBudget(systemPromptFor(message), options.budget) + const env = options.sandboxEnv ?? {} + assertEnvWithinLimits(env) + const profile = { + name: 'vertical-mini', + prompt: { systemPrompt: systemPromptFor(message) }, + resources: { files: options.profileFileContent ? [{ path: '/knowledge.md', content: options.profileFileContent }] : [] }, + } + // `ProvisionProfileSection` is structural, so a product-composed profile + // passes without casting through the SDK's AgentProfile. + assertProvisionPayloadWithinCap({ env, secrets: [], backend: { profile } }) + } + + interface TurnState { + partOrder: string[] + partMap: Map + announcedTools: Set + asked: InteractionRequestWire[] + resolutions: Map + tailParts: JsonRecord[] + receipt: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number; cost: number; seen: boolean } + failed: string | null + } + + function interactionStatusOf(state: TurnState, request: InteractionRequestWire): ChatInteractionStatus { + const resolution = state.resolutions.get(request.id) + if (!resolution) return 'pending' + return resolution.outcome === 'accepted' ? 'answered' : 'declined' + } + + /** Drives the scripted producer: encodes the client NDJSON lane and + * accumulates the persistence lane, then persists the assistant message. */ + function runTurn(threadId: string, turnId: string): ReadableStream { + const encoder = new TextEncoder() + const script = options.script ?? [] + return new ReadableStream({ + async start(controller) { + const emit = (line: unknown) => controller.enqueue(encoder.encode(`${JSON.stringify(line)}\n`)) + const state: TurnState = { + partOrder: [], + partMap: new Map(), + announcedTools: new Set(), + asked: [], + resolutions: new Map(), + tailParts: [], + receipt: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0, seen: false }, + failed: null, + } + emit({ type: 'turn', turnId }) + + for (const event of script) { + if (event.type === 'error') { + state.failed = event.message + emit({ type: 'error', error: 'Chat loop failed', details: event.message }) + break + } + + if (event.type === 'interaction') { + state.asked.push(event.request) + void sidecar.ask(event.request) + emit({ type: 'interaction', data: { request: event.request } }) + if (event.block !== false) { + const resolved = await sidecar.waitAll() + for (const [id, resolution] of resolved) state.resolutions.set(id, resolution) + } + continue + } + + const raw = event.part + const normalized = normalizePersistedPart(raw) + if (!normalized) { + // The normalizer owns only text/reasoning/tool; other canonical + // kinds (step-finish et al) persist verbatim. + if (String(raw.type ?? '') === 'step-finish') { + const tokens = (raw.tokens ?? {}) as { + input?: number; output?: number; reasoning?: number; cache?: { read?: number; write?: number } + } + state.receipt.seen = true + state.receipt.input += tokens.input ?? 0 + state.receipt.output += tokens.output ?? 0 + state.receipt.reasoning += tokens.reasoning ?? 0 + state.receipt.cacheRead += tokens.cache?.read ?? 0 + state.receipt.cacheWrite += tokens.cache?.write ?? 0 + state.receipt.cost += typeof raw.cost === 'number' ? raw.cost : 0 + state.tailParts.push(raw) + emit({ + kind: 'event', + event: { + type: 'usage', + usage: { promptTokens: tokens.input ?? 0, completionTokens: tokens.output ?? 0 }, + }, + }) + } + continue + } + + const key = getPartKey(normalized) + const existing = state.partMap.get(key) + if (!existing) state.partOrder.push(key) + state.partMap.set(key, mergePersistedPart(existing, normalized, event.delta)) + + const type = String(normalized.type ?? '') + if (type === 'text') { + emit({ kind: 'event', event: { type: 'text', text: event.delta ?? String(normalized.text ?? '') } }) + } else if (type === 'reasoning') { + emit({ kind: 'event', event: { type: 'reasoning', text: event.delta ?? String(normalized.text ?? '') } }) + } else if (type === 'tool') { + const merged = state.partMap.get(key)! + const toolState = (merged.state ?? {}) as { status?: string; input?: unknown; output?: unknown; error?: string } + const toolCallId = String(merged.id ?? '') + const toolName = String(merged.tool ?? 'tool') + if (!state.announcedTools.has(toolCallId)) { + state.announcedTools.add(toolCallId) + emit({ + kind: 'event', + event: { type: 'tool_call', call: { toolCallId, toolName, args: (toolState.input ?? {}) as Record } }, + }) + } + if (toolState.status === 'completed' || toolState.status === 'error') { + emit({ + kind: 'tool_result', + toolCallId, + toolName, + label: toolName, + outcome: toolState.status === 'completed' + ? { ok: true, result: toolState.output } + : { ok: false, message: toolState.error ?? 'tool failed' }, + }) + } + } + } + + // Persistence lane: same accumulated parts, one assistant row. + const orderedParts = state.partOrder + .map((key) => state.partMap.get(key)) + .filter((part): part is JsonRecord => Boolean(part)) + const finalText = orderedParts + .filter((part) => String(part.type ?? '') === 'text') + .map((part) => String(part.text ?? '')) + .join('') + const parts: JsonRecord[] = finalizeAssistantParts(state.partOrder, state.partMap, finalText) + for (const tail of state.tailParts) parts.push(tail) + for (const request of state.asked) { + parts.push(interactionToPersistedPart(request, interactionStatusOf(state, request))) + } + if (state.failed) { + parts.push(noticePart('warning', turnId, `The agent hit an error and this turn stopped: ${state.failed}`)) + } + await store.appendMessage({ + threadId, + role: 'assistant', + content: finalText, + parts: parts as unknown as ChatMessagePart[], + model: MINI_APP_MODEL, + ...(state.receipt.seen + ? { + inputTokens: state.receipt.input, + outputTokens: state.receipt.output, + reasoningTokens: state.receipt.reasoning, + cacheReadTokens: state.receipt.cacheRead, + cacheWriteTokens: state.receipt.cacheWrite, + costUsd: state.receipt.cost, + } + : {}), + }) + + emit({ type: 'metadata', data: { modelUsed: MINI_APP_MODEL } }) + emit({ type: 'turn_status', status: state.failed ? 'error' : 'complete' }) + controller.close() + }, + }) + } + + const chat = (request: Request) => + handle(async () => { + const session = await appAuth.requireApiUser(request) + const body = (await request.json().catch(() => null)) as + | { workspaceId?: string; threadId?: string; message?: string } + | null + const message = body?.message?.trim() + if (!body?.workspaceId || !message) { + return Response.json({ error: 'workspaceId and message are required' }, { status: 400 }) + } + assertMember(session, body.workspaceId) + + try { + runGates(message) + } catch (err) { + // The #190 gates throw Errors with the actionable breakdown — surface + // them as a client-visible 400 BEFORE any row is written. + return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 }) + } + + let threadId = body.threadId + if (threadId) { + const thread = await store.getThread(threadId) + if (!thread || thread.workspaceId !== body.workspaceId) { + return Response.json({ error: 'Thread not found' }, { status: 404 }) + } + } else { + const thread = await store.createThread({ workspaceId: body.workspaceId, firstMessage: message }) + threadId = thread.id + } + await store.appendMessage({ threadId, role: 'user', content: message }) + + turnCounter += 1 + const turnId = `turn-${turnCounter}` + return new Response(runTurn(threadId, turnId), { + headers: { + 'content-type': 'application/x-ndjson', + 'x-thread-id': threadId, + 'x-turn-id': turnId, + }, + }) + }) + + const listThreads = (request: Request) => + handle(async () => { + const session = await appAuth.requireApiUser(request) + const workspaceId = new URL(request.url).searchParams.get('workspaceId') ?? '' + assertMember(session, workspaceId) + const result = await store.listThreads({ workspaceId }) + return Response.json(result) + }) + + const getThread = (request: Request) => + handle(async () => { + const session = await appAuth.requireApiUser(request) + const threadId = new URL(request.url).searchParams.get('threadId') ?? '' + const thread = await store.getThread(threadId) + // Inaccessible reads are indistinguishable from missing ones — a + // cross-workspace probe must not learn the thread exists. + if (!thread || !isMember(session.user.email, thread.workspaceId)) { + return Response.json({ error: 'Thread not found' }, { status: 404 }) + } + const messages = await store.listMessages(threadId) + return Response.json({ thread, messages }) + }) + + const bulkDeleteThreads = (request: Request) => + handle(async () => { + const session = await appAuth.requireApiUser(request) + const body = (await request.json().catch(() => null)) as { ids?: string[] } | null + const result = await store.bulkDeleteThreads({ + ids: body?.ids ?? [], + assertAccess: (workspaceId) => { + if (!isMember(session.user.email, workspaceId)) throw deniedWorkspaceResponse() + }, + }) + return Response.json(result) + }) + + const interactions = createInteractionAnswerRoute({ + resolveConnection: async ({ request, intent, body }) => { + let session: AppAuthSession + try { + session = await appAuth.requireApiUser(request) + } catch (err) { + if (err instanceof Response) return { ok: false, response: err } + throw err + } + const threadId = + intent === 'answer' + ? String(body?.threadId ?? '') + : new URL(request.url).searchParams.get('threadId') ?? '' + const thread = await store.getThread(threadId) + if (!thread) return { ok: false, response: Response.json({ error: 'Thread not found' }, { status: 404 }) } + if (!isMember(session.user.email, thread.workspaceId)) { + return { ok: false, response: deniedWorkspaceResponse() } + } + return { ok: true, connection: sidecar.connection } + }, + logger: { warn: () => {}, error: () => {} }, + }) + + return { + appAuth, + store, + tables, + db, + sidecar, + routes: { chat, listThreads, getThread, bulkDeleteThreads, interactions }, + async signUp(email: string) { + const response = await appAuth.auth.handler( + new Request(`${BASE_URL}/api/auth/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE_URL }, + body: JSON.stringify({ email, password: 'correct-horse-battery', name: email.split('@')[0] }), + }), + ) + if (response.status !== 200) throw new Error(`sign-up failed (${response.status})`) + return response.headers + .getSetCookie() + .map((cookie) => cookie.split(';')[0]!) + .join('; ') + }, + grantMembership(email, workspaceId) { + const set = memberships.get(email) ?? new Set() + set.add(workspaceId) + memberships.set(email, set) + }, + async createWorkspace(id, name = id) { + await db.insert(workspacesTable).values({ id, organizationId: 'org-vertical', name }) + }, + } +} diff --git a/tests/vertical/vertical.test.ts b/tests/vertical/vertical.test.ts new file mode 100644 index 0000000..04c5ccd --- /dev/null +++ b/tests/vertical/vertical.test.ts @@ -0,0 +1,548 @@ +/** + * Vertical composition evals — the assembled mini chat app from `./mini-app` + * exercised end-to-end as a hostile integrator would: every assertion is a + * USER-VISIBLE outcome (what the client stream showed, what a later page load + * reads back from the store, what an attacker's request returned), never a + * module internal. + * + * This suite is the composition gate for the merged #189/#190/#191/#192 + * modules: a future subpath change that still passes its unit tests but breaks + * assembly (a seam rename, a contract drift between stream parts and persisted + * parts, a guard/store access mismatch) fails here. + */ + +import { describe, expect, it, vi } from 'vitest' +import { + consumeChatStream, + streamChatTurn, + type ChatStreamCallbacks, +} from '../../src/web-react/chat-stream' +import { + dedupeQuestionInteractionsByContent, + persistedPartToInteraction, + type ChatInteraction, + type InteractionRequestWire, +} from '../../src/interactions/index' +import type { ChatInteractionPart, ChatMessagePart, ChatStepFinishPart, ChatToolPart } from '../../src/chat-store/index' +import { createMiniApp, MINI_APP_MODEL, type MiniApp, type ProducerEvent } from './mini-app' + +const BASE = 'http://localhost:3000' + +// ── request helpers ────────────────────────────────────────────────────────── + +function jsonRequest(path: string, body: unknown, cookie?: string): Request { + return new Request(`${BASE}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(body), + }) +} + +function getRequest(path: string, cookie?: string): Request { + return new Request(`${BASE}${path}`, { headers: cookie ? { cookie } : {} }) +} + +function recorder() { + const log: Array<[string, unknown]> = [] + const interactions: ChatInteraction[] = [] + const cb: ChatStreamCallbacks = { + onText: (t) => log.push(['text', t]), + onReasoning: (t) => log.push(['reasoning', t]), + onToolCall: (c) => log.push(['tool_call', c.toolName]), + onToolResult: (r) => log.push(['tool_result', r.outcome?.ok]), + onUsage: (u) => log.push(['usage', u]), + onMetadata: (d) => log.push(['metadata', d.modelUsed]), + onErrorEvent: (m) => log.push(['error', m]), + onInteraction: (i) => { + log.push(['interaction', i.id]) + interactions.push(i) + }, + } + const text = () => log.filter(([k]) => k === 'text').map(([, v]) => v).join('') + return { log, cb, interactions, text } +} + +async function seedUser(app: MiniApp, email: string, workspaceIds: string[]): Promise { + const cookie = await app.signUp(email) + for (const workspaceId of workspaceIds) app.grantMembership(email, workspaceId) + return cookie +} + +async function messagesOf(app: MiniApp, threadId: string) { + return app.store.listMessages(threadId) +} + +function partsOf(message: { parts: ChatMessagePart[] | null }): ChatMessagePart[] { + return message.parts ?? [] +} + +// ── scripts ────────────────────────────────────────────────────────────────── + +const FULL_TURN_SCRIPT: ProducerEvent[] = [ + { type: 'message.part.updated', part: { type: 'reasoning', id: 'r1', text: 'checking the vault' }, delta: 'checking the vault' }, + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Filed ' }, delta: 'Filed ' }, + { + type: 'message.part.updated', + part: { type: 'tool', id: 'call-1', callID: 'call-1', tool: 'vault_search', state: { status: 'running', input: { query: 'lease' } } }, + }, + { + type: 'message.part.updated', + part: { + type: 'tool', + id: 'call-1', + callID: 'call-1', + tool: 'vault_search', + state: { status: 'completed', input: { query: 'lease' }, output: { hits: 2 }, time: { start: 1_000, end: 5_000 } }, + }, + }, + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Filed the lease summary.' }, delta: 'the lease summary.' }, + { + type: 'message.part.updated', + part: { + type: 'step-finish', + reason: 'stop', + tokens: { total: 65, input: 40, output: 20, reasoning: 5, cache: { read: 10, write: 2 } }, + cost: 0.0123, + }, + }, +] + +function question(id: string, overrides: Partial = {}): InteractionRequestWire { + return { + id, + kind: 'question', + title: 'Which tone should the summary use?', + answerSpec: { + fields: [ + { + type: 'select', + name: 'tone', + label: 'Tone', + required: true, + multi: false, + options: [ + { value: 'Formal', label: 'Formal' }, + { value: 'Casual', label: 'Casual' }, + ], + }, + ], + }, + ...overrides, + } as InteractionRequestWire +} + +// ── scenario 1: full turn ──────────────────────────────────────────────────── + +describe('vertical: full turn', () => { + it('streams text/tool/usage to the client and persists the assistant message with parts + token receipt', async () => { + const app = await createMiniApp({ script: FULL_TURN_SCRIPT }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const { log, cb, text } = recorder() + const response = await app.routes.chat( + jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Draft the lease summary\nfull details follow' }, cookie), + ) + expect(response.status).toBe(200) + const threadId = response.headers.get('x-thread-id')! + expect(threadId).toBeTruthy() + + const result = await consumeChatStream(response.body!, cb) + expect(result.receivedContent).toBe(true) + expect(result.turnId).toBe(response.headers.get('x-turn-id')) + + // Client lane: the user watched the whole turn happen. + expect(text()).toBe('Filed the lease summary.') + expect(log).toContainEqual(['reasoning', 'checking the vault']) + expect(log).toContainEqual(['tool_call', 'vault_search']) + expect(log).toContainEqual(['tool_result', true]) + expect(log).toContainEqual(['usage', { promptTokens: 40, completionTokens: 20 }]) + expect(log).toContainEqual(['metadata', MINI_APP_MODEL]) + + // Persistence lane: a reload reads the same turn back. + const thread = await app.store.getThread(threadId) + expect(thread?.title).toBe('Draft the lease summary') + + const messages = await messagesOf(app, threadId) + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant']) + expect(messages[0]?.content).toContain('Draft the lease summary') + + const assistant = messages[1]! + expect(assistant.content).toBe('Filed the lease summary.') + expect(assistant.model).toBe(MINI_APP_MODEL) + expect(assistant.inputTokens).toBe(40) + expect(assistant.outputTokens).toBe(20) + expect(assistant.reasoningTokens).toBe(5) + expect(assistant.cacheReadTokens).toBe(10) + expect(assistant.cacheWriteTokens).toBe(2) + expect(assistant.costUsd).toBeCloseTo(0.0123) + + const parts = partsOf(assistant) + const textPart = parts.find((p) => p.type === 'text') + expect(textPart).toMatchObject({ type: 'text', text: 'Filed the lease summary.' }) + + const toolPart = parts.find((p) => p.type === 'tool') as ChatToolPart | undefined + expect(toolPart).toMatchObject({ + type: 'tool', + tool: 'vault_search', + state: { status: 'completed', input: { query: 'lease' }, output: { hits: 2 } }, + }) + + const stepFinish = parts.find((p) => p.type === 'step-finish') as ChatStepFinishPart | undefined + expect(stepFinish?.tokens).toMatchObject({ input: 40, output: 20, reasoning: 5, cache: { read: 10, write: 2 } }) + expect(stepFinish?.cost).toBeCloseTo(0.0123) + }) + + it('rejects an unauthenticated turn with the guard 401 contract', async () => { + const app = await createMiniApp({ script: FULL_TURN_SCRIPT }) + await app.createWorkspace('ws1') + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'hi' })) + expect(response.status).toBe(401) + expect(await response.json()).toMatchObject({ code: 'auth.unauthenticated' }) + }) + + it('rejects a member-of-nothing user with 403 before any row is written', async () => { + const app = await createMiniApp({ script: FULL_TURN_SCRIPT }) + await app.createWorkspace('ws1') + const cookie = await app.signUp('lurker@example.com') // no membership granted + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'hi' }, cookie)) + expect(response.status).toBe(403) + const rows = await app.db.select().from(app.tables.threads) + expect(rows).toHaveLength(0) + }) +}) + +// ── scenario 2: failed turn (the incident class) ───────────────────────────── + +describe('vertical: failed turn', () => { + const FAILING_SCRIPT: ProducerEvent[] = [ + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Starting the review… ' }, delta: 'Starting the review… ' }, + { type: 'error', message: 'sandbox exec crashed: boom' }, + ] + + it('shows the error to a client with no onErrorEvent wired (synthesized transcript row) and retains the user message', async () => { + const app = await createMiniApp({ script: FAILING_SCRIPT }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const texts: string[] = [] + try { + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Review the contract' }, cookie)) + const threadId = response.headers.get('x-thread-id')! + const result = await consumeChatStream(response.body!, { onText: (t) => texts.push(t) }) + + // User-visible: the failure reached the transcript, not a silent empty answer. + expect(result.receivedContent).toBe(true) + expect(texts.join('')).toContain('sandbox exec crashed: boom') + expect(errorSpy).toHaveBeenCalled() + + // Durability: the user's message survived the failed turn, and the + // partial assistant output + a warning notice are readable on reload. + const messages = await messagesOf(app, threadId) + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant']) + expect(messages[0]?.content).toBe('Review the contract') + + const assistant = messages[1]! + expect(assistant.content).toBe('Starting the review… ') + const notice = partsOf(assistant).find((p) => p.type === 'notice') + expect(notice).toMatchObject({ type: 'notice', noticeKind: 'warning' }) + expect((notice as { text?: string }).text).toContain('sandbox exec crashed: boom') + } finally { + errorSpy.mockRestore() + } + }) + + it('routes the error to onErrorEvent when the consumer wired one', async () => { + const app = await createMiniApp({ script: FAILING_SCRIPT }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const { log, cb, text } = recorder() + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Review the contract' }, cookie)) + await consumeChatStream(response.body!, cb) + expect(log).toContainEqual(['error', 'sandbox exec crashed: boom']) + expect(text()).toBe('Starting the review… ') // no synthesized duplicate + }) +}) + +// ── scenario 3: interaction ask → answer → turn continues ──────────────────── + +describe('vertical: interaction round-trip', () => { + const ASKING_SCRIPT: ProducerEvent[] = [ + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Before I draft: ' }, delta: 'Before I draft: ' }, + { type: 'interaction', request: question('ask-1') }, + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Before I draft: got it, proceeding.' }, delta: 'got it, proceeding.' }, + { type: 'message.part.updated', part: { type: 'step-finish', tokens: { input: 10, output: 4 }, cost: 0.002 } }, + ] + + it('blocks the turn on the ask, rejects an invalid answer fail-closed, unblocks on a valid one, and persists the answered card', async () => { + const app = await createMiniApp({ script: ASKING_SCRIPT }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Draft the letter' }, cookie)) + const threadId = response.headers.get('x-thread-id')! + + const { cb, interactions, text } = recorder() + const answerFlow: Promise[] = [] + cb.onInteraction = (interaction) => { + interactions.push(interaction) + answerFlow.push((async () => { + // Hostile first: free text on an option-only select must fail closed + // with the card-actionable 400, and the run must stay blocked. + const bad = await app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: interaction.id, outcome: 'accepted', data: { tone: 'sarcastic' }, threadId }, cookie), + ) + expect(bad.status).toBe(400) + expect(await bad.json()).toMatchObject({ code: 'INVALID_INTERACTION_ANSWER' }) + expect(app.sidecar.outstandingIds()).toContain(interaction.id) + + const good = await app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: interaction.id, outcome: 'accepted', data: { tone: ['Formal'] }, threadId }, cookie), + ) + expect(good.status).toBe(200) + expect(await good.json()).toEqual({ ok: true }) + })()) + } + + const result = await consumeChatStream(response.body!, cb) + await Promise.all(answerFlow) + + // The turn genuinely continued past the ask. + expect(result.receivedContent).toBe(true) + expect(text()).toBe('Before I draft: got it, proceeding.') + expect(interactions).toHaveLength(1) + expect(interactions[0]).toMatchObject({ id: 'ask-1', kind: 'question', status: 'pending' }) + expect(app.sidecar.outstandingIds()).toEqual([]) + + // Reload restore: the persisted part reads back as an answered card. + const messages = await messagesOf(app, threadId) + const assistant = messages[1]! + const persisted = partsOf(assistant).find((p) => p.type === 'interaction') as ChatInteractionPart | undefined + expect(persisted).toMatchObject({ id: 'ask-1', kind: 'question', status: 'answered' }) + const card = persistedPartToInteraction(persisted as unknown as Record) + expect(card).toMatchObject({ id: 'ask-1', title: 'Which tone should the summary use?', status: 'answered' }) + + // The receipt still landed after the blocked stretch. + expect(assistant.inputTokens).toBe(10) + expect(assistant.outputTokens).toBe(4) + + // Answering an already-resolved ask reads back 410 (card flips to expired). + const replay = await app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: 'ask-1', outcome: 'accepted', data: { tone: ['Formal'] }, threadId }, cookie), + ) + expect(replay.status).toBe(410) + expect(await replay.json()).toMatchObject({ code: 'INTERACTION_EXPIRED' }) + }) + + it('a re-emitted duplicate question renders one card and one answer resolves both asks', async () => { + const script: ProducerEvent[] = [ + { type: 'interaction', request: question('ask-1'), block: false }, + { type: 'interaction', request: question('ask-2') }, // same content, new id + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Proceeding.' }, delta: 'Proceeding.' }, + ] + const app = await createMiniApp({ script }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Draft it' }, cookie)) + const threadId = response.headers.get('x-thread-id')! + + const { cb, interactions, text } = recorder() + const answered: Promise[] = [] + cb.onInteraction = (interaction) => { + interactions.push(interaction) + if (interaction.id !== 'ask-1') return + answered.push(app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: 'ask-1', outcome: 'accepted', data: { tone: ['Casual'] }, threadId }, cookie), + )) + } + await consumeChatStream(response.body!, cb) + const [answerResponse] = await Promise.all(answered) + + // The user saw ONE card even though the agent asked twice. + expect(interactions).toHaveLength(2) + expect(dedupeQuestionInteractionsByContent(interactions)).toHaveLength(1) + + // One POST resolved both registry entries (content-signature duplicate handling). + expect(answerResponse!.status).toBe(200) + expect(app.sidecar.outstandingIds()).toEqual([]) + expect(app.sidecar.calls.some((call) => call.method === 'POST' && call.body?.id === 'ask-2')).toBe(true) + expect(text()).toBe('Proceeding.') + }) + + it('denies answering another workspace\'s ask and listing its interactions', async () => { + const app = await createMiniApp({ script: [{ type: 'interaction', request: question('ask-1') }] }) + await app.createWorkspace('ws1') + await app.createWorkspace('ws2') + const adaCookie = await seedUser(app, 'ada@example.com', ['ws1']) + const bobCookie = await seedUser(app, 'bob@example.com', ['ws2']) + + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Draft it' }, adaCookie)) + const threadId = response.headers.get('x-thread-id')! + // The turn is parked on the ask; attack it from the other workspace. + const denied = await app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: 'ask-1', outcome: 'accepted', data: { tone: ['Formal'] }, threadId }, bobCookie), + ) + expect(denied.status).toBe(403) + expect(app.sidecar.outstandingIds()).toContain('ask-1') + + const deniedList = await app.routes.interactions.list(getRequest(`/api/chat/interactions?threadId=${threadId}`, bobCookie)) + expect(deniedList.status).toBe(403) + + const okList = await app.routes.interactions.list(getRequest(`/api/chat/interactions?threadId=${threadId}`, adaCookie)) + expect(okList.status).toBe(200) + expect(await okList.json()).toMatchObject({ interactions: [{ id: 'ask-1' }] }) + + // Release the parked turn so the stream (and the test) can finish. + const ok = await app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: 'ask-1', outcome: 'declined', threadId }, adaCookie), + ) + expect(ok.status).toBe(200) + await consumeChatStream(response.body!, {}) + }) +}) + +// ── scenario 4: workspace isolation + cross-workspace reads ───────────────── + +describe('vertical: workspace isolation', () => { + async function twoWorkspaceApp() { + const app = await createMiniApp({ script: FULL_TURN_SCRIPT }) + await app.createWorkspace('ws1') + await app.createWorkspace('ws2') + const adaCookie = await seedUser(app, 'ada@example.com', ['ws1']) + const bobCookie = await seedUser(app, 'bob@example.com', ['ws2']) + + const adaTurn = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Ada thread' }, adaCookie)) + await consumeChatStream(adaTurn.body!, {}) + const bobTurn = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws2', message: 'Bob thread' }, bobCookie)) + await consumeChatStream(bobTurn.body!, {}) + + return { app, adaCookie, bobCookie, adaThreadId: adaTurn.headers.get('x-thread-id')!, bobThreadId: bobTurn.headers.get('x-thread-id')! } + } + + it('a cross-workspace thread read is indistinguishable from a missing thread', async () => { + const { app, adaCookie, bobCookie, adaThreadId } = await twoWorkspaceApp() + + const denied = await app.routes.getThread(getRequest(`/api/thread?threadId=${adaThreadId}`, bobCookie)) + expect(denied.status).toBe(404) + const missing = await app.routes.getThread(getRequest('/api/thread?threadId=does-not-exist', bobCookie)) + expect(missing.status).toBe(404) + expect(await denied.json()).toEqual(await missing.json()) + + const unauthenticated = await app.routes.getThread(getRequest(`/api/thread?threadId=${adaThreadId}`)) + expect(unauthenticated.status).toBe(401) + + const owner = await app.routes.getThread(getRequest(`/api/thread?threadId=${adaThreadId}`, adaCookie)) + expect(owner.status).toBe(200) + const payload = await owner.json() as { messages: Array<{ role: string }> } + expect(payload.messages.map((m) => m.role)).toEqual(['user', 'assistant']) + }) + + it('a second workspace never sees the first workspace\'s threads', async () => { + const { app, adaCookie, bobCookie, adaThreadId, bobThreadId } = await twoWorkspaceApp() + + const bobList = await app.routes.listThreads(getRequest('/api/threads?workspaceId=ws2', bobCookie)) + const bobPayload = await bobList.json() as { threads: Array<{ id: string }>; total: number } + expect(bobPayload.total).toBe(1) + expect(bobPayload.threads.map((t) => t.id)).toEqual([bobThreadId]) + expect(bobPayload.threads.map((t) => t.id)).not.toContain(adaThreadId) + + // Listing a workspace you are not a member of is denied outright. + const denied = await app.routes.listThreads(getRequest('/api/threads?workspaceId=ws1', bobCookie)) + expect(denied.status).toBe(403) + + const adaList = await app.routes.listThreads(getRequest('/api/threads?workspaceId=ws1', adaCookie)) + expect((await adaList.json() as { threads: Array<{ id: string }> }).threads.map((t) => t.id)).toEqual([adaThreadId]) + }) + + it('bulk delete with one denied workspace deletes NOTHING (fail-closed)', async () => { + const { app, adaCookie, adaThreadId, bobThreadId } = await twoWorkspaceApp() + + const denied = await app.routes.bulkDeleteThreads( + jsonRequest('/api/threads/bulk-delete', { ids: [adaThreadId, bobThreadId] }, adaCookie), + ) + expect(denied.status).toBe(403) + + // Zero deletion: both threads AND their messages survived the rejected request. + expect(await app.store.getThread(adaThreadId)).not.toBeNull() + expect(await app.store.getThread(bobThreadId)).not.toBeNull() + expect(await messagesOf(app, adaThreadId)).toHaveLength(2) + expect(await messagesOf(app, bobThreadId)).toHaveLength(2) + + // The same caller deleting only their own thread succeeds, messages first. + const ok = await app.routes.bulkDeleteThreads(jsonRequest('/api/threads/bulk-delete', { ids: [adaThreadId] }, adaCookie)) + expect(ok.status).toBe(200) + expect(await ok.json()).toEqual({ deleted: 1 }) + expect(await app.store.getThread(adaThreadId)).toBeNull() + expect(await messagesOf(app, adaThreadId)).toHaveLength(0) + expect(await app.store.getThread(bobThreadId)).not.toBeNull() + }) + + it('maps malformed bulk-delete input to a 400, not a 500', async () => { + const { app, adaCookie } = await twoWorkspaceApp() + const empty = await app.routes.bulkDeleteThreads(jsonRequest('/api/threads/bulk-delete', { ids: [] }, adaCookie)) + expect(empty.status).toBe(400) + expect(await empty.json()).toMatchObject({ error: 'Missing ids' }) + }) +}) + +// ── scenario 5: the #190 gates at the turn boundary ────────────────────────── + +describe('vertical: incident gates', () => { + it('an oversized composed prompt is a client-visible 400 with the section breakdown, before any row is written', async () => { + const knowledgeDump = 'k'.repeat(60_000) + const app = await createMiniApp({ + script: FULL_TURN_SCRIPT, + systemPromptFor: (message) => `# Directives\nAnswer: ${message}\n# Knowledge Dump\n${knowledgeDump}`, + }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + // User-visible path: streamChatTurn surfaces the server's gate message. + await expect( + streamChatTurn({ + start: () => app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'hi' }, cookie)), + callbacks: {}, + }), + ).rejects.toThrow(/over the 40000-byte budget.*Knowledge Dump/s) + + // Fired pre-write: no orphan thread or user message. + expect(await app.db.select().from(app.tables.threads)).toHaveLength(0) + expect(await app.db.select().from(app.tables.messages)).toHaveLength(0) + }) + + it('an oversized env entry fires the E2BIG gate naming the variable', async () => { + const app = await createMiniApp({ + script: FULL_TURN_SCRIPT, + sandboxEnv: { WORKSPACE_CONTEXT: 'x'.repeat(130_000) }, + }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'hi' }, cookie)) + expect(response.status).toBe(400) + const { error } = await response.json() as { error: string } + expect(error).toContain('WORKSPACE_CONTEXT') + expect(error).toContain('131072') + expect(await app.db.select().from(app.tables.messages)).toHaveLength(0) + }) + + it('an oversized provision payload fires the create-cap gate with the per-section breakdown', async () => { + const app = await createMiniApp({ + script: FULL_TURN_SCRIPT, + profileFileContent: 'f'.repeat(250_000), + }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'hi' }, cookie)) + expect(response.status).toBe(400) + const { error } = await response.json() as { error: string } + expect(error).toContain('over the 240000-byte gate') + expect(error).toContain('Breakdown:') + expect(error).toMatch(/files=\d+B/) + expect(await app.db.select().from(app.tables.threads)).toHaveLength(0) + }) +})