From 46c20543a1923c9849cfcc21e4ed223c7550d6a8 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 15 Jul 2026 13:11:09 -0600 Subject: [PATCH] =?UTF-8?q?feat(chat-routes):=20assembled=20chat-turn=20ve?= =?UTF-8?q?rtical=20+=20multimodal=20upload=E2=86=92parts=20(#188=20Phase?= =?UTF-8?q?=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + examples/chat-app.md | 159 +++++++ package.json | 5 + src/chat-routes/index.ts | 13 + src/chat-routes/sandbox-producer.ts | 258 +++++++++++ src/chat-routes/turn-routes.ts | 482 ++++++++++++++++++++ src/chat-routes/upload.ts | 167 +++++++ src/chat-routes/wire.ts | 137 ++++++ src/chat-store/parts.ts | 64 +++ src/index.ts | 4 +- src/platform/guards.ts | 18 + src/stream/stream-normalizer.ts | 56 ++- src/web-react/chat-composer.test.tsx | 52 +++ src/web-react/chat-composer.tsx | 51 ++- src/web-react/chat-stream.ts | 9 + tests/chat-routes/sandbox-producer.test.ts | 159 +++++++ tests/chat-routes/turn-routes.test.ts | 307 +++++++++++++ tests/chat-routes/upload.test.ts | 129 ++++++ tests/chat-store/parts-projection.test.ts | 37 ++ tests/platform.test.ts | 17 + tests/stream-normalizer.test.ts | 38 ++ tests/vertical/chat-routes-vertical.test.ts | 151 ++++++ tests/vertical/mini-app.ts | 45 +- tsup.config.ts | 1 + 24 files changed, 2326 insertions(+), 34 deletions(-) create mode 100644 examples/chat-app.md create mode 100644 src/chat-routes/index.ts create mode 100644 src/chat-routes/sandbox-producer.ts create mode 100644 src/chat-routes/turn-routes.ts create mode 100644 src/chat-routes/upload.ts create mode 100644 src/chat-routes/wire.ts create mode 100644 tests/chat-routes/sandbox-producer.test.ts create mode 100644 tests/chat-routes/turn-routes.test.ts create mode 100644 tests/chat-routes/upload.test.ts create mode 100644 tests/chat-store/parts-projection.test.ts create mode 100644 tests/vertical/chat-routes-vertical.test.ts diff --git a/AGENTS.md b/AGENTS.md index ef953eb..a01db61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Corollary — **extend, never duplicate.** Before writing anything that complete | `/eval` | `producedFromToolEvents` (side-channel→`RuntimeEventLike` bridge) + `createTokenRecallChecker` | **re-exports** agent-eval's `verifyCompletion`/`extractProducedState`/`weightedComposite`/`createLlmCorrectnessChecker` | | `/integrations` | hub `/exec` client + `resolveIntegrationAction` + `invokeIntegrationHub` (wiring) | peer-dep `@tangle-network/agent-integrations` (the engine/catalog) | | `/interactions` | human-in-the-loop ask channel, both halves: the shared wire/persisted-part contract (`ChatInteraction`, part codecs, composer-as-answer routing, content-signature dedupe) + the server side — structural sidecar `/agents/sessions/{id}/interactions` client and `createInteractionAnswerRoute()` (list/answer endpoint factory: body validation, gone→410 mapping, duplicate-answer safety net, unblock verification, `resolveConnection` product seam) | peer `@tangle-network/agent-interface` (schema types only); connection is structural — no sandbox-SDK import. Server half must never reach client bundles (`tests/interactions-browser-safe.test.ts`) | +| `/chat-routes` | the assembled server chat vertical (#188 Phase 1): `createChatTurnRoutes()` — body parse/validate → injected `authorize` seam → producer seam → turn-buffer tap wired BY DEFAULT → NDJSON `Response` + replay endpoint + composed `/interactions` answer endpoints + `/chat-store` persistence (user row on send, assistant row with parts/usage on completion); `createSandboxChatProducer` (raw sandbox events → client vocabulary + persisted parts + usage receipt, non-renderable-ask auto-decline); `createUploadRoute` (multipart → inline `data:` part ≤700 KiB, else base64 write through a structural sandbox sink + path-ref part — the >1 MiB gateway cap makes the two-step mandatory); import-free `./wire` contract (`ChatTurnRequestPayload`, inline-part byte gate) re-exported via `/web-react`'s chat-stream | peer `@tangle-network/agent-runtime` (`handleChatTurn` IS the turn engine — zero loop logic here; subpath-only, not in the root barrel); composes `/stream`, `/chat-store`, `/interactions`, `/web`. Reference assembly: `examples/chat-app.md` | | `/tangle` | app-registration consent URL + cached broker-token provider | structural `TangleAppsClient` (from agent-integrations) | | `/billing` | per-workspace budget-capped key manager (mint/rotate/rollover/usage) | structural tcloud provisioner + store + crypto seams | | `/missions` | durable multi-step mission orchestration: guarded status/step machine + cursor + cost ledger over a `MissionStorePort` (CAS updates → typed conflict, opaque `extras` insert passthrough for product columns), idempotent plan engine (cached-done short-circuit, cursor reconciliation, retryable-vs-deterministic failure, detached-session polling), budget/classification/volume gates that park as `waiting_approval`, `:::mission` parser, client-safe live-event reducer + the canonical `StepAgentActivity` per-step delegated-run lane (`step.updated` snapshot, latest-wins) | — (substrate-free; product supplies storage, `SandboxDispatch`, approvals port, `classifyStep`) | diff --git a/examples/chat-app.md b/examples/chat-app.md new file mode 100644 index 0000000..7fa8479 --- /dev/null +++ b/examples/chat-app.md @@ -0,0 +1,159 @@ +# A multimodal chat app on the assembled vertical + +The whole server chat vertical — auth, thread/message tables, streaming turn +with buffered replay, file uploads, sidecar question answering — assembled from +factories. No hand-rolled orchestration: the turn engine is agent-runtime's +`handleChatTurn`, durability is `/stream`'s turn buffer, persistence is +`/chat-store`, asks are `/interactions`. This file is the seed of the +`create-agent-app --chat` scaffold. + +Who owns each hop: + +| Hop | Owner | +| --- | --- | +| Session auth + guards | `/app-auth` (`createAppAuth`) | +| Thread/message tables + CRUD | `/chat-store` (`createChatTables` + `createChatStore`) | +| Body parse, turn identity, routes | `/chat-routes` (`createChatTurnRoutes`) | +| Turn engine (NDJSON protocol, hook order) | agent-runtime `handleChatTurn` | +| Sandbox events → client vocabulary + persisted parts | `/chat-routes` (`createSandboxChatProducer`) | +| Buffered replay after a drop | `/stream` turn buffer (wired by default) | +| Upload → `PromptInputPart` descriptors | `/chat-routes` (`createUploadRoute`) | +| Ask answering (list/answer, 410 mapping, dedupe) | `/interactions` via `routes.interactions` | +| Composer, stream consumption, cards | `/web-react` | + +## Schema (drizzle + one migration constant) + +```ts +// db/schema.ts +import { createChatTables } from '@tangle-network/agent-app/chat-store' +import { TURN_EVENTS_MIGRATION_SQL } from '@tangle-network/agent-app/stream' // append to migrations + +export const { threads, messages } = createChatTables({ workspaceTable: workspaces }) +``` + +## Server (one worker route file) + +```ts +// server/chat.ts +import { createAppAuth } from '@tangle-network/agent-app/app-auth' +import { + createChatTurnRoutes, createSandboxChatProducer, createUploadRoute, +} from '@tangle-network/agent-app/chat-routes' +import { createChatStore } from '@tangle-network/agent-app/chat-store' +import { guardResolution } from '@tangle-network/agent-app/platform' +import { ensureWorkspaceSandbox, streamSandboxPrompt } from '@tangle-network/agent-app/sandbox' +import { createD1TurnEventStore } from '@tangle-network/agent-app/stream' +import { drizzle } from 'drizzle-orm/d1' +import { shell } from './sandbox-shell' // your SandboxRuntimeConfig (see build-agent-app) +import { messages, threads, users, sessions, accounts, verifications } from '../db/schema' + +export function buildChat(env: Env) { + const db = drizzle(env.DB) + const { requireApiUser } = createAppAuth({ + appName: 'Acme Agent', baseURL: env.BETTER_AUTH_URL, secret: env.BETTER_AUTH_SECRET, + db, schema: { users, sessions, accounts, verifications }, + }) + + // The guard throws a JSON 401; guardResolution adapts it to {ok, response}. + const authorize = async ({ request }: { request: Request }) => { + const auth = await guardResolution(() => requireApiUser(request)) + if (!auth.ok) return auth + const { user } = auth.value + return { ok: true as const, tenantId: user.id, userId: user.id, context: { user } } + } + + const routes = createChatTurnRoutes({ + projectId: 'acme-agent', + authorize, + store: createChatStore(db, { threads, messages }), + turnStore: createD1TurnEventStore(env.DB), + produce: async ({ prompt, body, identity, executionId }) => { + const box = await ensureWorkspaceSandbox(shell, { + workspaceId: identity.tenantId, userId: identity.userId, harness: 'opencode', + }) + return createSandboxChatProducer({ + model: body.model, + events: streamSandboxPrompt(shell, box, prompt, { + sessionId: identity.sessionId, executionId, + model: body.model, effort: body.effort, + interactions: { question: true, plan: true }, + }), + }) + }, + interactions: { + resolveConnection: async ({ request, body }) => { + const auth = await authorize({ request }) + if (!auth.ok) return auth + const threadId = String(body?.threadId ?? new URL(request.url).searchParams.get('threadId') ?? '') + const box = await ensureWorkspaceSandbox(shell, { workspaceId: auth.userId, harness: 'opencode' }) + const c = box.connection + if (!c?.runtimeUrl) return { ok: false as const, unavailable: 'SANDBOX_UNAVAILABLE' } + // sessionId = the agent session the turn streams under (the thread id). + return { ok: true as const, connection: { runtimeUrl: c.runtimeUrl, authToken: c.authToken, sessionId: threadId } } + }, + }, + }) + + const upload = createUploadRoute({ + authorize: async ({ request }) => { + const auth = await authorize({ request }) + if (!auth.ok) return auth + const box = await ensureWorkspaceSandbox(shell, { workspaceId: auth.userId, harness: 'opencode' }) + return { ok: true as const, sink: box.fs } + }, + }) + + return { routes, upload } +} + +// worker fetch handler +export async function handleChat(request: Request, env: Env, ctx: ExecutionContext) { + const { routes, upload } = buildChat(env) + const url = new URL(request.url) + if (url.pathname === '/api/chat' && request.method === 'POST') return routes.turn(request, ctx) + const replay = url.pathname.match(/^\/api\/chat\/replay\/([^/]+)$/) + if (replay) return routes.replay(request, { turnId: replay[1]! }) + if (url.pathname === '/api/chat/upload') return upload(request) + if (url.pathname === '/api/chat/interactions' && request.method === 'GET') return routes.interactions!.list(request) + if (url.pathname === '/api/chat/interactions' && request.method === 'POST') return routes.interactions!.answer(request) + return new Response('Not found', { status: 404 }) +} +``` + +## Client (composer → parts → stream → resume) + +```tsx +// app/chat.tsx +import { ChatComposer, chatTurnRequestInit, streamChatTurn, type ComposerFile } from '@tangle-network/agent-app/web-react' + +function Chat({ threadId }: { threadId: string }) { + const [files, setFiles] = useState([]) + + async function attach(list: FileList) { + const form = new FormData() + for (const f of Array.from(list)) form.append('files', f) + const res = await fetch('/api/chat/upload', { method: 'POST', body: form }) + const { files: uploaded } = await res.json() + setFiles((prev) => [...prev, ...uploaded.map((u) => ({ + id: u.id, name: u.name, size: u.size, kind: 'file' as const, status: 'ready' as const, part: u.part, + }))]) + } + + async function send(content: string, parts: ComposerFile['part'][]) { + setFiles([]) + await streamChatTurn({ + start: () => fetch('/api/chat', chatTurnRequestInit({ threadId, content, parts })), + resume: (turnId, fromSeq) => fetch(`/api/chat/replay/${turnId}?fromSeq=${fromSeq}`), + callbacks: { onText: appendDelta, onToolCall: showToolChip, onInteraction: showQuestionCard }, + }) + } + + return setFiles((p) => p.filter((f) => f.id !== id))} /> +} +``` + +Uploads ≤700 KiB come back as inline `data:` parts; bigger files are written +into the sandbox workspace and referenced by `path` (the ~1 MiB gateway body +cap makes that two-step mandatory). Question cards render with +`InteractionQuestionCard` + `useChatInteractions` and answer through +`/api/chat/interactions` — see `/web-react`. diff --git a/package.json b/package.json index ed19af8..7dbda13 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,11 @@ "import": "./dist/chat-store/index.js", "default": "./dist/chat-store/index.js" }, + "./chat-routes": { + "types": "./dist/chat-routes/index.d.ts", + "import": "./dist/chat-routes/index.js", + "default": "./dist/chat-routes/index.js" + }, "./crypto": { "types": "./dist/crypto/index.d.ts", "import": "./dist/crypto/index.js", diff --git a/src/chat-routes/index.ts b/src/chat-routes/index.ts new file mode 100644 index 0000000..34e81a8 --- /dev/null +++ b/src/chat-routes/index.ts @@ -0,0 +1,13 @@ +/** + * `/chat-routes` — the assembled server chat vertical (issue #188 Phase 1). + * + * Subpath-only (NOT re-exported from the root barrel): `turn-routes` imports + * the optional `@tangle-network/agent-runtime` peer at module top, same rule + * as `/app-auth`. The browser-safe wire contract lives in `./wire` and is + * re-exported through `/web-react`'s chat-stream glue. + */ + +export * from './wire' +export * from './turn-routes' +export * from './sandbox-producer' +export * from './upload' diff --git a/src/chat-routes/sandbox-producer.ts b/src/chat-routes/sandbox-producer.ts new file mode 100644 index 0000000..763c77f --- /dev/null +++ b/src/chat-routes/sandbox-producer.ts @@ -0,0 +1,258 @@ +/** + * Sandbox lane: bridge a raw sandbox event stream (`streamSandboxPrompt`) into + * the `ChatTurnProducer` shape agent-runtime's `handleChatTurn` consumes AND + * the client vocabulary `/web-react`'s `dispatchChatStreamLine` already parses + * (`text` / `reasoning` / `tool_call` / `tool_result` / `usage` / + * `interaction`). Legal and tax each hand-rolled this mapping differently; + * this is that middle, composed from `/stream`'s normalizers — no new loop + * logic, no SDK import (the event source is an injected `AsyncIterable`). + * + * Alongside the live mapping it accumulates the PERSISTED projection — the + * `message.parts` rows `/chat-store` stores — via `normalizePersistedPart` / + * `mergePersistedPart` / `finalizeAssistantParts`, plus the usage receipt from + * `step-finish` parts. `createChatTurnRoutes` reads both after drain. + */ + +import { + isRenderableInteractionKind, + parseInteractionRequest, +} from '../interactions/contract' +import { + asRecord, + asString, + finalizeAssistantParts, + getPartKey, + mergePersistedPart, + normalizePersistedPart, + normalizeToolEvent, + type JsonRecord, + type StreamEvent, +} from '../stream/index' +import type { ChatTurnRouteProducer, ChatTurnUsage } from './turn-routes' + +export interface SandboxChatProducerOptions { + /** The raw sandbox event stream (e.g. `streamSandboxPrompt(...)`). */ + events: AsyncIterable + /** Recorded on the persisted assistant message. */ + model?: string + /** Which ask kinds the product renders a card for. Anything else is + * auto-declined (see `declineInteraction`) so the run never hangs in the + * broker waiting on a card no client will show. Default: question/plan. */ + isRenderableInteraction?: (kind: string) => boolean + /** Resolve a non-renderable ask (wire `respondToSessionInteraction` with the + * session's sidecar connection). Without it, non-renderable asks are only + * logged — the run stays blocked until the broker times out. */ + declineInteraction?: (id: string) => Promise + log?: (message: string, meta?: Record) => void +} + +interface TextTracker { + /** Full accumulated text per part key, to derive suffix deltas from + * snapshot-only harness events. */ + seen: Map +} + +/** Delta to emit for one text/reasoning part update: prefer the harness's + * explicit delta; otherwise diff the snapshot against what was already + * emitted for that part (snapshot-only harnesses re-send the whole text). */ +function textDelta(tracker: TextTracker, key: string, part: JsonRecord, rawDelta: unknown): string { + const explicit = typeof rawDelta === 'string' ? rawDelta : undefined + const previous = tracker.seen.get(key) ?? '' + if (explicit !== undefined) { + tracker.seen.set(key, previous + explicit) + return explicit + } + const snapshot = asString(part.text) ?? asString(part.content) ?? '' + if (!snapshot) return '' + if (snapshot.startsWith(previous)) { + tracker.seen.set(key, snapshot) + return snapshot.slice(previous.length) + } + // The snapshot replaced the text outright — emit it whole; the persisted + // projection stays correct because finalText is authoritative at finalize. + tracker.seen.set(key, snapshot) + return snapshot +} + +function usageFromStepFinish(part: JsonRecord, usage: ChatTurnUsage): void { + const tokens = asRecord(part.tokens) + if (tokens) { + const cache = asRecord(tokens.cache) + const add = (current: number | undefined, value: unknown): number | undefined => { + const n = Number(value) + if (!Number.isFinite(n)) return current + return (current ?? 0) + n + } + usage.inputTokens = add(usage.inputTokens, tokens.input) + usage.outputTokens = add(usage.outputTokens, tokens.output) + usage.reasoningTokens = add(usage.reasoningTokens, tokens.reasoning) + if (cache) { + usage.cacheReadTokens = add(usage.cacheReadTokens, cache.read) + usage.cacheWriteTokens = add(usage.cacheWriteTokens, cache.write) + } + } + const cost = Number(part.cost) + if (Number.isFinite(cost)) usage.costUsd = (usage.costUsd ?? 0) + cost +} + +export function createSandboxChatProducer(options: SandboxChatProducerOptions): ChatTurnRouteProducer { + const log = options.log ?? ((message, meta) => console.error(message, meta ?? '')) + const renderable = options.isRenderableInteraction ?? isRenderableInteractionKind + + let fullText = '' + const partOrder: string[] = [] + const partMap = new Map() + const tracker: TextTracker = { seen: new Map() } + const usage: ChatTurnUsage = {} + /** Tool ids already announced as `tool_call` / settled as `tool_result`. */ + const announcedTools = new Set() + const settledTools = new Set() + /** Id-less step boundaries: one occurrence per key, never merged. */ + let stepCounter = 0 + + function recordPersistedPart(part: JsonRecord, delta: string | undefined, keyOverride?: string): void { + const persisted = normalizePersistedPart(part) + if (!persisted) return + const key = keyOverride ?? getPartKey(persisted) + if (!partMap.has(key)) partOrder.push(key) + partMap.set(key, mergePersistedPart(partMap.get(key), persisted, delta)) + } + + async function* stream(): AsyncGenerator { + for await (const raw of options.events) { + const record = asRecord(raw) + if (!record || typeof record.type !== 'string') continue + // Fold bare tool_call/tool_result shapes into the canonical part event; + // everything else keeps its original record (verbatim forwarding must + // not strip fields outside `data`). + const normalized = normalizeToolEvent({ type: record.type, data: asRecord(record.data) }) + const event = normalized.type === 'message.part.updated' ? normalized : (record as unknown as StreamEvent) + + if (event.type === 'message.part.updated') { + const part = asRecord(event.data?.part) + if (!part) continue + const rawDelta = event.data?.delta + const partType = String(part.type ?? '') + + if (partType === 'text' || partType === 'reasoning') { + const key = getPartKey(part) + const delta = textDelta(tracker, key, part, rawDelta) + recordPersistedPart(part, delta || undefined) + if (delta) { + if (partType === 'text') fullText += delta + yield { type: partType, text: delta } as StreamEvent & { text: string } + } + continue + } + + if (partType === 'tool') { + recordPersistedPart(part, undefined) + const persisted = partMap.get(getPartKey(part)) + const state = asRecord(persisted?.state) + const toolId = String(persisted?.id ?? '') + const toolName = String(persisted?.tool ?? 'tool') + if (toolId && !announcedTools.has(toolId)) { + announcedTools.add(toolId) + yield { + type: 'tool_call', + call: { toolCallId: toolId, toolName, args: asRecord(state?.input) ?? {} }, + } as StreamEvent + } + const status = String(state?.status ?? '') + if (toolId && (status === 'completed' || status === 'error') && !settledTools.has(toolId)) { + settledTools.add(toolId) + yield { + type: 'tool_result', + toolCallId: toolId, + toolName, + outcome: { + ok: status === 'completed', + ...(state?.output !== undefined ? { result: state.output } : {}), + ...(asString(state?.error) ? { message: asString(state?.error) } : {}), + }, + } as StreamEvent + } + continue + } + + if (partType === 'step-finish') { + usageFromStepFinish(part, usage) + // Persist the per-step receipt too (unique key per occurrence: the + // parts have no id and two receipts must never merge into one). + recordPersistedPart(part, undefined, `step-finish:#${stepCounter++}`) + const promptTokens = usage.inputTokens ?? 0 + const completionTokens = usage.outputTokens ?? 0 + if (promptTokens || completionTokens) { + yield { type: 'usage', usage: { promptTokens, completionTokens } } as StreamEvent + } + continue + } + + if (partType === 'step-start') { + recordPersistedPart(part, undefined, `step-start:#${stepCounter}`) + continue + } + + // Remaining storable kinds (file/image/subtask) have no live + // vocabulary line; they persist so the transcript keeps them. + recordPersistedPart(part, undefined) + continue + } + + if (event.type === 'interaction') { + const parsed = parseInteractionRequest(asRecord(record.data)) + if (!parsed.succeeded) { + log('[chat-routes] dropping malformed interaction event', { error: parsed.error }) + continue + } + if (renderable(parsed.value.kind)) { + yield event + continue + } + // Non-renderable ask: the run is blocked in the broker until someone + // answers. Decline it so the turn proceeds instead of hanging. + if (options.declineInteraction) { + try { + await options.declineInteraction(parsed.value.id) + } catch (err) { + log('[chat-routes] failed to auto-decline interaction', { + id: parsed.value.id, + error: err instanceof Error ? err.message : String(err), + }) + } + } else { + log('[chat-routes] non-renderable interaction with no declineInteraction wired', { + id: parsed.value.id, + kind: parsed.value.kind, + }) + } + continue + } + + if (event.type === 'result') { + const finalText = asString(event.data?.finalText) + if (finalText) fullText = finalText + const resultUsage = asRecord(event.data?.usage) + if (resultUsage) { + const input = Number(resultUsage.inputTokens) + const output = Number(resultUsage.outputTokens) + if (Number.isFinite(input)) usage.inputTokens = input + if (Number.isFinite(output)) usage.outputTokens = output + } + continue + } + + // Everything else (interaction.cancel, error, lifecycle) forwards + // verbatim — the client parser ignores unknown types. + yield event + } + } + + return { + stream: stream(), + finalText: () => fullText, + assistantParts: () => finalizeAssistantParts(partOrder, partMap, fullText), + usage: () => usage, + ...(options.model ? { model: options.model } : {}), + } +} diff --git a/src/chat-routes/turn-routes.ts b/src/chat-routes/turn-routes.ts new file mode 100644 index 0000000..fa9bdc4 --- /dev/null +++ b/src/chat-routes/turn-routes.ts @@ -0,0 +1,482 @@ +/** + * `createChatTurnRoutes` — the assembled server chat vertical (issue #188 + * Phase 1). One factory composing the pieces every product re-wired by hand: + * + * body parse/validate → `/web` `parseJsonObjectBody` + `./wire` + * turn identity → `/stream` `resolveChatTurn` + agent-runtime + * `deriveExecutionId` + * producer → injected seam (sandbox lane via + * `createSandboxChatProducer`; router lane is the + * product's own `ChatTurnProducer`) + * turn engine → agent-runtime `handleChatTurn` (verbatim) + * durability → `/stream` turn-buffer tap, wired BY DEFAULT + * (tee + drain keeps the turn running after a + * client drop; replay serves the buffered tail) + * persistence → injected `/chat-store`-shaped store + * (user row on send, assistant row on completion) + * interactions answer → `/interactions` `createInteractionAnswerRoute` + * + * Handlers are web-standard `Request → Response` (Workers, Node 18+, Deno) — + * no router import. Auth/access is one injected `authorize` seam, composable + * with `/app-auth` guards but not coupled to them. + */ + +import { deriveExecutionId, handleChatTurn } from '@tangle-network/agent-runtime' +import type { ChatTurnIdentity, ChatTurnProducer } from '@tangle-network/agent-runtime' +import { toChatMessageParts, type ChatMessagePart } from '../chat-store/parts' +import { + createInteractionAnswerRoute, + type InteractionAnswerRoute, + type InteractionAnswerRouteOptions, +} from '../interactions/route' +import { + coalesceDeltas, + createBufferedTurnTap, + normalizeClientTurnId, + replayTurnEvents, + resolveChatTurn, + type PersistedChatMessageForTurn, + type TurnEventStore, +} from '../stream/index' +import { parseJsonObjectBody } from '../web/index' +import { + assertPromptPartsWithinCap, + ChatTurnInputError, + parseChatTurnParts, + type ChatTurnFilePartInput, + type ChatTurnPartInput, + type ChatTurnRequestPayload, +} from './wire' + +// ── seams ─────────────────────────────────────────────────────────────────── + +/** Usage receipt persisted onto the assistant message (the flattened + * `step-finish` shape `/chat-store`'s columns mirror). */ +export interface ChatTurnUsage { + inputTokens?: number + outputTokens?: number + reasoningTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + costUsd?: number +} + +/** What the route persists — a structural subset of `/chat-store`'s + * `ChatStore`, so `createChatStore(db, tables)` satisfies it directly and a + * product with its own persistence adapts without importing drizzle. */ +export interface ChatTurnMessageStore { + listMessages(threadId: string): Promise> + appendMessage(input: { + threadId: string + role: 'user' | 'assistant' + content: string + parts?: ChatMessagePart[] + model?: string | null + inputTokens?: number | null + outputTokens?: number | null + reasoningTokens?: number | null + cacheReadTokens?: number | null + cacheWriteTokens?: number | null + costUsd?: number | null + }): Promise +} + +/** `ChatTurnProducer` plus the persisted projection the assembly reads after + * drain. `createSandboxChatProducer` returns this; a router-lane producer + * may omit the optional members (finalText persists as a single text part). */ +export interface ChatTurnRouteProducer extends ChatTurnProducer { + assistantParts?(): Array> + usage?(): ChatTurnUsage + model?: string +} + +export type ChatTurnAuthorization = + | { ok: true; tenantId: string; userId: string; context: TContext } + | { ok: false; response: Response } + +export interface ChatTurnAuthorizeArgs { + request: Request + intent: 'turn' | 'replay' + /** Parsed, validated POST body (turn intent only). */ + body?: ChatTurnRequestPayload + /** The buffered turn id being replayed (replay intent only). */ + turnId?: string +} + +export interface ChatTurnProduceArgs { + request: Request + body: ChatTurnRequestPayload + identity: ChatTurnIdentity + context: TContext + /** The message to send: plain text, or parts when the client attached + * files (a text part is prepended from `content` when present). */ + prompt: string | ChatTurnPartInput[] + /** Stable id for cross-process reconnect (`deriveExecutionId`). */ + executionId: string + /** The turn-buffer id announced to the client for replay. */ + turnStreamId: string + priorMessages: PersistedChatMessageForTurn[] +} + +export interface CreateChatTurnRoutesOptions { + /** Names the product in `deriveExecutionId` so retries land on the same + * substrate execution. */ + projectId: string + /** Authenticate + authorize the caller for a turn or a replay. The only + * product-supplied access step: session auth, thread/workspace access, + * seat/balance gates, rate limits all live here. */ + authorize(args: ChatTurnAuthorizeArgs): Promise> + /** Thread/message persistence (`/chat-store`'s store or a product adapter). */ + store: ChatTurnMessageStore + /** Turn-event buffer (`createD1TurnEventStore(env.DB)` in production, + * `createMemoryTurnEventStore()` in tests). Wired by default — every turn + * is buffered and replayable. */ + turnStore: TurnEventStore + /** Build the turn's event stream. Sandbox lane: `streamSandboxPrompt(...)` + * wrapped in `createSandboxChatProducer`. Router/openai-compat lane: the + * product's own producer. May be async (box resolution). */ + produce(args: ChatTurnProduceArgs): ChatTurnRouteProducer | Promise + /** Pre-persist transform of the final text (e.g. `/redact`'s `redactPII`). + * Live stream is never altered. */ + transformFinalText?(text: string): string | Promise + /** Post-processing after a successful turn (billing, titles, audit). Errors + * are swallowed by the engine — they never fail a streamed turn. */ + onTurnComplete?(input: { identity: ChatTurnIdentity; finalText: string; context: TContext }): Promise + /** Per-event side channel (product broadcast). The turn-buffer tap is + * already wired; this runs in addition. */ + onEvent?(event: { type: string; data?: Record }, context: TContext): void | Promise + /** Trace flush handed to `waitUntil` (OTLP export). */ + traceFlush?(context: TContext): Promise + /** Compose the interaction-answer endpoints (`/interactions`). Omit when the + * product has no sidecar ask channel. */ + interactions?: InteractionAnswerRouteOptions + /** Byte budget for inline prompt parts. Default `INLINE_PARTS_MAX_BYTES`. */ + maxInlinePartBytes?: number + /** Per-flush coalescer for the turn buffer. Default `coalesceDeltas` (this + * assembly streams the client vocabulary's `{type:'text'|'reasoning', + * text}` lines, which it merges). A producer streaming raw + * `message.part.updated` events passes `coalesceChatStreamEvents`. */ + coalesceTurnEvents?: (events: unknown[]) => unknown[] + replay?: { pollMs?: number; timeoutMs?: number } + log?: (message: string, meta?: Record) => void +} + +export interface ChatTurnRoutes { + /** POST — run one turn, streaming NDJSON. First line is + * `{type:'turn', turnId}` (the replay handle); the rest is the engine's + * event protocol. Pass the platform's `waitUntil` so the turn keeps + * running (and buffering) after a client disconnect. */ + turn(request: Request, ctx?: { waitUntil?(p: Promise): void }): Promise + /** GET — replay a buffered turn from `?fromSeq=` (0 = everything), then + * follow it live until it completes. */ + replay(request: Request, params: { turnId: string }): Promise + /** list/answer endpoints from `/interactions`; null when not configured. */ + interactions: InteractionAnswerRoute | null +} + +// ── body validation ──────────────────────────────────────────────────────── + +function errorResponse(err: ChatTurnInputError): Response { + return Response.json({ code: err.code, error: err.message }, { status: err.status }) +} + +interface ParsedTurnBody { + payload: ChatTurnRequestPayload + content: string + fileParts: ChatTurnFilePartInput[] + turnId: string | undefined +} + +function validateTurnBody(body: Record, maxInlinePartBytes: number | undefined): ParsedTurnBody { + const threadId = typeof body.threadId === 'string' ? body.threadId.trim() : '' + if (!threadId) throw new ChatTurnInputError('Missing threadId') + const rawContent = body.content ?? body.message ?? '' + if (typeof rawContent !== 'string') throw new ChatTurnInputError('content must be a string') + const content = rawContent.trim() + const fileParts = parseChatTurnParts(body.parts) + if (!content && fileParts.length === 0) { + throw new ChatTurnInputError('Missing content (send text, parts, or both)') + } + assertPromptPartsWithinCap(fileParts, maxInlinePartBytes) + let turnId: string | undefined + try { + turnId = normalizeClientTurnId(body.turnId) + } catch (err) { + throw new ChatTurnInputError(err instanceof Error ? err.message : 'Invalid turnId') + } + return { + payload: { ...body, threadId, content } as ChatTurnRequestPayload, + content, + fileParts, + turnId, + } +} + +/** File parts persist onto the user message verbatim — the wire shape is the + * persisted `ChatFilePart`/`ChatImagePart` vocabulary already. The typed + * projection is `/chat-store`'s (same boundary as the assistant hop). */ +function userPartsWithFiles( + userParts: Array>, + fileParts: ChatTurnFilePartInput[], +): ChatMessagePart[] { + return toChatMessageParts([...userParts, ...fileParts.map((part) => ({ ...part }))]) +} + +// ── the factory ──────────────────────────────────────────────────────────── + +export function createChatTurnRoutes( + options: CreateChatTurnRoutesOptions, +): ChatTurnRoutes { + const log = options.log ?? ((message, meta) => console.error(message, meta ?? '')) + + async function turn(request: Request, ctx?: { waitUntil?(p: Promise): void }): Promise { + const [rawBody, badBody] = await parseJsonObjectBody(request) + if (badBody) return badBody + + let parsed: ParsedTurnBody + try { + parsed = validateTurnBody(rawBody, options.maxInlinePartBytes) + } catch (err) { + if (err instanceof ChatTurnInputError) return errorResponse(err) + throw err + } + const { payload, content, fileParts, turnId } = parsed + + const auth = await options.authorize({ request, intent: 'turn', body: payload }) + if (!auth.ok) return auth.response + const { tenantId, userId, context } = auth + + // Turn identity: reuse the just-persisted user row on a retry (same + // turnId or identical trailing content) instead of double-inserting. + const existingMessages = (await options.store.listMessages(payload.threadId)).map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + parts: (m.parts ?? null) as PersistedChatMessageForTurn['parts'], + })) + const chatTurn = resolveChatTurn({ existingMessages, userContent: content, turnId }) + + if (chatTurn.shouldInsertUserMessage) { + await options.store.appendMessage({ + threadId: payload.threadId, + role: 'user', + content, + parts: userPartsWithFiles(chatTurn.userParts, fileParts), + }) + } + + const identity: ChatTurnIdentity = { + tenantId, + sessionId: payload.threadId, + userId, + turnIndex: chatTurn.turnIndex, + } + const executionId = deriveExecutionId({ + projectId: options.projectId, + sessionId: payload.threadId, + turnIndex: chatTurn.turnIndex, + }) + const turnStreamId = crypto.randomUUID() + + const prompt: string | ChatTurnPartInput[] = + fileParts.length === 0 + ? content + : content + ? [{ type: 'text', text: content }, ...fileParts] + : [...fileParts] + + // Durability tap: every engine event buffers (coalesced) so a dropped + // client replays the tail. Live delivery rides the Response body, not the + // tap, so `write` is intentionally absent. + const tap = createBufferedTurnTap({ + store: options.turnStore, + turnId: turnStreamId, + scopeId: payload.threadId, + coalesce: options.coalesceTurnEvents ?? coalesceDeltas, + }) + const turnMarker = { type: 'turn', turnId: turnStreamId } + await tap.onEvent(turnMarker) + + let producer: ChatTurnRouteProducer | undefined + let runFailed = false + + const result = handleChatTurn({ + identity, + waitUntil: ctx?.waitUntil, + log, + hooks: { + // The engine wants a synchronous producer; box resolution is async — + // defer it into the generator's first pull. + produce: () => ({ + stream: (async function* () { + producer = await options.produce({ + request, + body: payload, + identity, + context, + prompt, + executionId, + turnStreamId, + priorMessages: chatTurn.priorMessages, + }) + for await (const event of producer.stream) yield event + })(), + finalText: () => producer?.finalText() ?? '', + }), + onEvent: async (event) => { + if (event.type === 'session.run.failed' || event.type === 'error') runFailed = true + await tap.onEvent(event) + if (options.onEvent) await options.onEvent(event, context) + }, + ...(options.transformFinalText ? { transformFinalText: options.transformFinalText } : {}), + persistAssistantMessage: async ({ finalText }) => { + // The typed boundary: stream-normalizer records → stored vocabulary + // (validating projection owned by /chat-store — no cast here). + const parts = producer?.assistantParts ? toChatMessageParts(producer.assistantParts()) : undefined + if (!finalText.trim() && (!parts || parts.length === 0)) return + const usage = producer?.usage?.() ?? {} + await options.store.appendMessage({ + threadId: payload.threadId, + role: 'assistant', + content: finalText, + ...(parts && parts.length > 0 ? { parts } : {}), + ...(producer?.model ? { model: producer.model } : {}), + ...(usage.inputTokens !== undefined ? { inputTokens: usage.inputTokens } : {}), + ...(usage.outputTokens !== undefined ? { outputTokens: usage.outputTokens } : {}), + ...(usage.reasoningTokens !== undefined ? { reasoningTokens: usage.reasoningTokens } : {}), + ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}), + ...(usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: usage.cacheWriteTokens } : {}), + ...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}), + }) + }, + ...(options.onTurnComplete + ? { + onTurnComplete: ({ identity: turnIdentity, finalText }: { identity: ChatTurnIdentity; finalText: string }) => + options.onTurnComplete!({ identity: turnIdentity, finalText, context }), + } + : {}), + ...(options.traceFlush ? { traceFlush: () => options.traceFlush!(context) } : {}), + }, + }) + + // Tee: one branch to the live client, one drained under waitUntil so the + // turn (and its buffering via onEvent) runs to completion after a client + // drop — the engine body executes as it is pulled. + const [clientBody, drainBody] = result.body.tee() + const drained = (async () => { + const reader = drainBody.getReader() + try { + for (;;) { + const { done } = await reader.read() + if (done) break + } + await tap.done(runFailed ? 'error' : 'complete') + } catch (err) { + await tap.done('error') + log('[chat-routes] turn drain failed', { + turnId: turnStreamId, + error: err instanceof Error ? err.message : String(err), + }) + } + })() + if (ctx?.waitUntil) ctx.waitUntil(drained) + else void drained.catch(() => {}) + + // Announce the replay handle before the engine's first event. + const encoder = new TextEncoder() + const marker = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`${JSON.stringify(turnMarker)}\n`)) + controller.close() + }, + }) + const body = concatStreams([marker, clientBody]) + + return new Response(body, { + headers: { + 'Content-Type': result.contentType, + 'Cache-Control': 'no-cache', + }, + }) + } + + async function replay(request: Request, params: { turnId: string }): Promise { + const turnId = params.turnId?.trim() + if (!turnId) return Response.json({ error: 'Missing turnId' }, { status: 400 }) + const auth = await options.authorize({ request, intent: 'replay', turnId }) + if (!auth.ok) return auth.response + + const fromSeqRaw = new URL(request.url).searchParams.get('fromSeq') + const fromSeq = fromSeqRaw ? Math.max(0, Math.trunc(Number(fromSeqRaw)) || 0) : 0 + + const encoder = new TextEncoder() + const events = replayTurnEvents({ + store: options.turnStore, + turnId, + fromSeq, + ...(options.replay?.pollMs !== undefined ? { pollMs: options.replay.pollMs } : {}), + ...(options.replay?.timeoutMs !== undefined ? { timeoutMs: options.replay.timeoutMs } : {}), + }) + const body = new ReadableStream({ + async pull(controller) { + const { done, value } = await events.next() + if (done) { + controller.close() + return + } + controller.enqueue(encoder.encode(`${value.event}\n`)) + }, + cancel() { + void events.return(undefined) + }, + }) + return new Response(body, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache', + }, + }) + } + + return { + turn, + replay, + interactions: options.interactions ? createInteractionAnswerRoute(options.interactions) : null, + } +} + +/** Sequential concat of byte streams (marker line, then the engine body). */ +function concatStreams(streams: ReadableStream[]): ReadableStream { + let index = 0 + let reader: ReadableStreamDefaultReader | null = null + return new ReadableStream({ + async pull(controller) { + for (;;) { + if (!reader) { + const next = streams[index++] + if (!next) { + controller.close() + return + } + reader = next.getReader() + } + const { done, value } = await reader.read() + if (done) { + reader = null + continue + } + controller.enqueue(value) + return + } + }, + async cancel(reason) { + await reader?.cancel(reason) + for (const stream of streams.slice(index)) await stream.cancel(reason) + }, + }) +} diff --git a/src/chat-routes/upload.ts b/src/chat-routes/upload.ts new file mode 100644 index 0000000..8c95fcb --- /dev/null +++ b/src/chat-routes/upload.ts @@ -0,0 +1,167 @@ +/** + * `createUploadRoute` — the multimodal middle. Accepts multipart file uploads + * and returns `PromptInputPart`-shaped descriptors the client echoes back on + * send (`ChatTurnRequestPayload.parts`): + * + * ≤ inlineMaxBytes (700 KiB default) → inline `data:` URI part — rides the + * turn body directly, no sandbox round trip. + * > inlineMaxBytes → written into the sandbox workspace (base64 through the + * structural `write` seam — `box.fs` satisfies it) and referenced by + * `path`. Mandatory two-step: the gateway caps request bodies at ~1 MiB, + * so a large file can never ride the prompt POST. + * + * The sink is structural (no sandbox-SDK import); products pass `box.fs`. + */ + +import type { ChatTurnFilePartInput } from './wire' + +/** 700 KiB: base64 inflates ~4/3, so an inline part stays comfortably under + * the ~1 MiB gateway body cap alongside the JSON envelope. */ +export const UPLOAD_INLINE_MAX_BYTES = 700 * 1024 + +/** 8 MiB default ceiling per file — one base64 `write` call handles it. Raise + * it only with a sink that can take the bigger single write. */ +export const UPLOAD_MAX_FILE_BYTES = 8 * 1024 * 1024 + +/** Structural match of the sandbox SDK's `box.fs` write surface (v0.10.5+: + * `encoding: 'base64'` is the worker-safe binary path). */ +export interface SandboxUploadSink { + write(path: string, content: string, options?: { encoding?: 'utf8' | 'base64' }): Promise +} + +export type UploadAuthorization = + | { + ok: true + /** Where large files land. Absent/null: only inline uploads are + * accepted and an over-inline-cap file is rejected with 413. */ + sink?: SandboxUploadSink | null + /** Per-request override of the workspace directory large files go to. */ + uploadDir?: string + } + | { ok: false; response: Response } + +export interface CreateUploadRouteOptions { + /** Authenticate the caller and resolve the sandbox file sink (usually + * `ensureWorkspaceSandbox(...)` → `box.fs`). */ + authorize(args: { request: Request }): Promise + /** Inline-vs-sandbox threshold. Default {@link UPLOAD_INLINE_MAX_BYTES}. */ + inlineMaxBytes?: number + /** Hard per-file cap. Default {@link UPLOAD_MAX_FILE_BYTES}. */ + maxFileBytes?: number + /** Workspace directory for path-ref files. Default `'uploads'`. */ + uploadDir?: string +} + +/** One uploaded file, ready for the composer chip and the turn body. */ +export interface UploadedChatFile { + id: string + name: string + size: number + mediaType: string + /** True when the part carries the bytes inline (`data:` URI). */ + inline: boolean + /** Echo this back verbatim in `ChatTurnRequestPayload.parts`. */ + part: ChatTurnFilePartInput +} + +/** Path-safe file name: basename only, conservative charset, length-capped. */ +export function sanitizeUploadFilename(name: string): string { + const base = name.split(/[\\/]/).pop() ?? 'file' + const safe = base.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^\.+/, '_') + return (safe || 'file').slice(0, 120) +} + +const BASE64_CHUNK = 0x8000 + +export function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK)) + } + return btoa(binary) +} + +function uploadError(status: number, code: string, error: string): Response { + return Response.json({ code, error }, { status }) +} + +export function createUploadRoute(options: CreateUploadRouteOptions): (request: Request) => Promise { + const inlineMaxBytes = options.inlineMaxBytes ?? UPLOAD_INLINE_MAX_BYTES + const maxFileBytes = options.maxFileBytes ?? UPLOAD_MAX_FILE_BYTES + + return async function upload(request: Request): Promise { + const auth = await options.authorize({ request }) + if (!auth.ok) return auth.response + const sink = auth.sink ?? null + const uploadDir = (auth.uploadDir ?? options.uploadDir ?? 'uploads').replace(/\/+$/, '') + + let form: FormData + try { + form = await request.formData() + } catch { + return uploadError(400, 'INVALID_UPLOAD', 'Expected a multipart/form-data body with file fields') + } + const files: File[] = [] + form.forEach((value) => { + if (value instanceof File) files.push(value) + }) + if (files.length === 0) { + return uploadError(400, 'INVALID_UPLOAD', 'No files in the upload body') + } + + const uploaded: UploadedChatFile[] = [] + for (const file of files) { + const name = sanitizeUploadFilename(file.name) + const mediaType = file.type || 'application/octet-stream' + const partType: ChatTurnFilePartInput['type'] = mediaType.startsWith('image/') ? 'image' : 'file' + + if (file.size > maxFileBytes) { + return uploadError( + 413, + 'FILE_TOO_LARGE', + `${name} is ${file.size}B, over the ${maxFileBytes}B per-file cap`, + ) + } + + const id = crypto.randomUUID() + if (file.size <= inlineMaxBytes) { + const base64 = bytesToBase64(new Uint8Array(await file.arrayBuffer())) + uploaded.push({ + id, + name, + size: file.size, + mediaType, + inline: true, + part: { + type: partType, + filename: name, + mediaType, + url: `data:${mediaType};base64,${base64}`, + }, + }) + continue + } + + if (!sink) { + return uploadError( + 413, + 'SANDBOX_REQUIRED', + `${name} is ${file.size}B, over the ${inlineMaxBytes}B inline cap, and no sandbox is available to hold it`, + ) + } + const path = `${uploadDir}/${id}-${name}` + const base64 = bytesToBase64(new Uint8Array(await file.arrayBuffer())) + await sink.write(path, base64, { encoding: 'base64' }) + uploaded.push({ + id, + name, + size: file.size, + mediaType, + inline: false, + part: { type: partType, filename: name, mediaType, path }, + }) + } + + return Response.json({ files: uploaded }) + } +} diff --git a/src/chat-routes/wire.ts b/src/chat-routes/wire.ts new file mode 100644 index 0000000..d96bca7 --- /dev/null +++ b/src/chat-routes/wire.ts @@ -0,0 +1,137 @@ +/** + * Wire contract between the chat client (composer + `streamChatTurn`) and the + * assembled server vertical (`createChatTurnRoutes`). Import-free on purpose: + * `/web-react` re-exports these types into browser bundles, so nothing here may + * reach a Node builtin or an engine package. + * + * The part shape mirrors the sandbox SDK's `PromptInputPart` structurally + * (text | image | file with filename/mediaType/url/path/content) — derived + * here, not imported, so the client bundle never touches the SDK. + */ + +export interface ChatTurnTextPartInput { + type: 'text' + text: string +} + +/** A non-text prompt part the upload route hands back and the client echoes + * on send. `url` carries an inline `data:` URI for small files; `path` is a + * sandbox workspace reference for large ones (the >1 MiB gateway body cap + * makes the two-step upload mandatory). */ +export interface ChatTurnFilePartInput { + type: 'image' | 'file' + filename?: string + mediaType?: string + url?: string + path?: string + content?: string +} + +export type ChatTurnPartInput = ChatTurnTextPartInput | ChatTurnFilePartInput + +/** POST body for the turn route. `content` may be empty when `parts` carry the + * message (an image-only send). Product routing fields (workspaceId etc.) ride + * alongside and are read by the product's `authorize` seam. */ +export interface ChatTurnRequestPayload { + threadId: string + content?: string + /** Non-text parts from the upload route, echoed back verbatim. */ + parts?: ChatTurnFilePartInput[] + model?: string + effort?: 'auto' | 'low' | 'medium' | 'high' + harness?: string + /** Client-generated idempotency key for the logical turn (retry-safe). */ + turnId?: string + [key: string]: unknown +} + +/** `fetch` init for the turn route — the one place the client wire shape is + * serialized, so composer glue and products never drift from the server's + * parser. */ +export function chatTurnRequestInit(payload: ChatTurnRequestPayload): RequestInit { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + } +} + +// ── inline-part byte budget ───────────────────────────────────────────────── +// +// The sandbox gateway caps request bodies at 1 MiB; a turn body whose inline +// `data:` parts exceed it dies at the gateway with an opaque 413. Enforce the +// budget at the route boundary instead, with headroom for the JSON envelope +// (same fail-loud-at-the-choke-point style as /sandbox's provision-payload and +// env-size gates). + +export const INLINE_PARTS_MAX_BYTES = 950_000 + +export class ChatTurnInputError extends Error { + constructor(message: string, readonly status = 400, readonly code = 'INVALID_CHAT_TURN') { + super(message) + this.name = 'ChatTurnInputError' + } +} + +function partByteSize(part: ChatTurnPartInput): number { + let bytes = 0 + if (part.type === 'text') return part.text.length + if (part.url) bytes += part.url.length + if (part.content) bytes += part.content.length + if (part.path) bytes += part.path.length + return bytes +} + +export function promptPartsByteSize(parts: ChatTurnPartInput[]): number { + return parts.reduce((total, part) => total + partByteSize(part), 0) +} + +/** Throws `ChatTurnInputError` (413) when the parts' inline payload would blow + * the gateway cap. Path-ref parts are tiny by construction and always pass. */ +export function assertPromptPartsWithinCap( + parts: ChatTurnPartInput[], + maxBytes = INLINE_PARTS_MAX_BYTES, +): void { + const total = promptPartsByteSize(parts) + if (total <= maxBytes) return + const largest = [...parts].sort((a, b) => partByteSize(b) - partByteSize(a))[0] + const largestName = largest && largest.type !== 'text' ? largest.filename ?? largest.path ?? largest.type : 'text' + throw new ChatTurnInputError( + `Inline prompt parts total ${total}B, over the ${maxBytes}B budget (largest: ${largestName}, ${largest ? partByteSize(largest) : 0}B). ` + + 'Upload large files through the upload route so they travel as sandbox path references.', + 413, + 'PROMPT_PARTS_TOO_LARGE', + ) +} + +/** Validates the untyped `parts` array off the wire. Returns the typed parts + * or throws `ChatTurnInputError` (400) naming the offending entry. */ +export function parseChatTurnParts(raw: unknown): ChatTurnFilePartInput[] { + if (raw === undefined || raw === null) return [] + if (!Array.isArray(raw)) throw new ChatTurnInputError('parts must be an array') + return raw.map((entry, index) => { + const part = entry as Record | null + if (!part || typeof part !== 'object') { + throw new ChatTurnInputError(`parts[${index}] must be an object`) + } + if (part.type !== 'image' && part.type !== 'file') { + throw new ChatTurnInputError(`parts[${index}].type must be 'image' or 'file'`) + } + for (const key of ['filename', 'mediaType', 'url', 'path', 'content'] as const) { + if (part[key] !== undefined && typeof part[key] !== 'string') { + throw new ChatTurnInputError(`parts[${index}].${key} must be a string`) + } + } + if (!part.url && !part.path && !part.content) { + throw new ChatTurnInputError(`parts[${index}] needs a url, path, or content`) + } + return { + type: part.type, + ...(part.filename !== undefined ? { filename: part.filename as string } : {}), + ...(part.mediaType !== undefined ? { mediaType: part.mediaType as string } : {}), + ...(part.url !== undefined ? { url: part.url as string } : {}), + ...(part.path !== undefined ? { path: part.path as string } : {}), + ...(part.content !== undefined ? { content: part.content as string } : {}), + } + }) +} diff --git a/src/chat-store/parts.ts b/src/chat-store/parts.ts index fe8dc01..794e467 100644 --- a/src/chat-store/parts.ts +++ b/src/chat-store/parts.ts @@ -196,6 +196,70 @@ export type ChatMessagePart = * the persisted vocabulary. */ export type StorableHarnessPartKind = HarnessWirePart['type'] & ChatMessagePart['type'] +/** + * The typed projection at the `/stream` → `/chat-store` boundary. The stream + * normalizers (`normalizePersistedPart`/`mergePersistedPart`/ + * `finalizeAssistantParts`) deliberately produce untyped `JsonRecord`s — they + * normalize wire shapes and do not own the stored vocabulary. THIS module + * owns it, so this is where rows gain the `ChatMessagePart` type: each entry + * is validated against its kind's required fields and narrowed, junk is + * dropped, and — enforced by the exhaustiveness check below — no storable + * kind can silently fall out (the step-finish/interaction trap). + */ +export function toChatMessageParts(parts: Array>): ChatMessagePart[] { + const out: ChatMessagePart[] = [] + for (const part of parts) { + const typed = toChatMessagePart(part) + if (typed) out.push(typed) + } + return out +} + +const str = (value: unknown): value is string => typeof value === 'string' + +function toChatMessagePart(part: Record): ChatMessagePart | null { + if (!part || typeof part !== 'object') return null + const type = part.type as ChatMessagePart['type'] | undefined + switch (type) { + case 'text': + case 'reasoning': + return str(part.text) ? (part as unknown as ChatTextPart | ChatReasoningPart) : null + case 'tool': + return str(part.id) && str(part.tool) && part.state && typeof part.state === 'object' + ? (part as unknown as ChatToolPart) + : null + case 'file': + case 'image': + return part as unknown as ChatFilePart | ChatImagePart + case 'subtask': + return str(part.prompt) && str(part.description) && str(part.agent) + ? (part as unknown as ChatSubtaskPart) + : null + case 'step-start': + return { type: 'step-start' } + case 'step-finish': + return part as unknown as ChatStepFinishPart + case 'interaction': + return str(part.id) && str(part.kind) && str(part.title) && str(part.status) && + part.answerSpec && typeof part.answerSpec === 'object' + ? (part as unknown as ChatInteractionPart) + : null + case 'notice': + return str(part.id) && str(part.noticeKind) && str(part.text) + ? (part as unknown as ChatNoticePart) + : null + case undefined: + return null + default: { + // Compile-time exhaustiveness: a new ChatMessagePart kind that is not + // handled above makes `type` non-never here and this line fails. + const _exhaustive: never = type + void _exhaustive + return null + } + } +} + export function isChatToolPart(part: ChatMessagePart): part is ChatToolPart { return part.type === 'tool' } diff --git a/src/index.ts b/src/index.ts index 5e483e4..b08d802 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,9 @@ export * from './assets/index' export * from './theme/index' // `/app-auth` is intentionally NOT re-exported here: it imports the optional // better-auth peer at module top (same rule as `/platform`, which stays -// subpath-only for its structural seams). +// subpath-only for its structural seams). `/chat-routes` likewise stays +// subpath-only — it imports the optional agent-runtime peer at module top; +// its browser-safe wire contract is re-exported via `/web-react`. // `/web-react` and `/sequences-react` are intentionally NOT re-exported here: // they need the optional react peer and would drag JSX into every root-entry // consumer. `/sequences/drizzle` likewise stays subpath-only — it imports the diff --git a/src/platform/guards.ts b/src/platform/guards.ts index 718a6ca..6a817fe 100644 --- a/src/platform/guards.ts +++ b/src/platform/guards.ts @@ -47,6 +47,24 @@ export function createAuthGuard(opts: AuthGuardOptions): AuthG } } +export type GuardResolution = { ok: true; value: T } | { ok: false; response: Response } + +/** + * Adapt a guard that THROWS a Response (the quartet above — the router + * convention) to the `{ok: true, value} | {ok: false, response}` resolution + * shape the route factories take (`/chat-routes` `authorize`, + * `/interactions` `resolveConnection`, `/chat-routes` upload `authorize`). + * Every product wrote this try/catch by hand; it lives here once. + */ +export async function guardResolution(run: () => Promise): Promise> { + try { + return { ok: true, value: await run() } + } catch (err) { + if (err instanceof Response) return { ok: false, response: err } + throw err + } +} + /** Comma/whitespace separated → trimmed, lowercased, empties dropped. */ export function parseAdminEmails(raw: string | null | undefined): string[] { return (raw ?? '') diff --git a/src/stream/stream-normalizer.ts b/src/stream/stream-normalizer.ts index 76704cc..c51d7ac 100644 --- a/src/stream/stream-normalizer.ts +++ b/src/stream/stream-normalizer.ts @@ -108,6 +108,53 @@ export function normalizePersistedPart(rawPart: JsonRecord): JsonRecord | null { } } + if (type === 'file' || type === 'image') { + const id = asString(rawPart.id) ?? asString(rawPart.partId) + return { + type, + ...(id ? { id } : {}), + ...(asString(rawPart.filename) ? { filename: asString(rawPart.filename) } : {}), + ...(asString(rawPart.mediaType) ? { mediaType: asString(rawPart.mediaType) } : {}), + ...(asString(rawPart.url) ? { url: asString(rawPart.url) } : {}), + ...(asString(rawPart.path) ? { path: asString(rawPart.path) } : {}), + ...(type === 'file' && asString(rawPart.content) ? { content: asString(rawPart.content) } : {}), + } + } + + if (type === 'step-start') { + return { type: 'step-start' } + } + + // The harness's per-step usage receipt. Dropping it here silently loses the + // turn's token/cost accounting from the persisted transcript. + if (type === 'step-finish') { + const tokens = asRecord(rawPart.tokens) + const cost = Number(rawPart.cost) + return { + type: 'step-finish', + ...(asString(rawPart.reason) ? { reason: asString(rawPart.reason) } : {}), + ...(tokens ? { tokens } : {}), + ...(Number.isFinite(cost) ? { cost } : {}), + } + } + + if (type === 'subtask') { + const id = asString(rawPart.id) ?? asString(rawPart.partId) + return { + type: 'subtask', + prompt: asString(rawPart.prompt) ?? '', + description: asString(rawPart.description) ?? '', + agent: asString(rawPart.agent) ?? '', + ...(id ? { id } : {}), + } + } + + // System-authored persisted parts (`/interactions` codecs) pass through + // verbatim when a producer routes them here — they are already projections. + if (type === 'interaction' || type === 'notice') { + return rawPart + } + if (type === 'tool') { const state = asRecord(rawPart.state) const output = state?.output ?? rawPart.output @@ -155,11 +202,10 @@ export function getPartKey(part: JsonRecord): string { return `tool:${resolveToolId(part)}` } - if (type === 'reasoning') { - return `reasoning:${String(part.id ?? part.partId ?? part.index ?? 'current')}` - } - - return `text:${String(part.id ?? part.partId ?? part.index ?? 'current')}` + // Keyed by the part's OWN type so distinct kinds never merge into each + // other. Untyped parts fall back to the text lane (legacy shape). + const lane = type && type !== 'unknown' ? type : 'text' + return `${lane}:${String(part.id ?? part.partId ?? part.index ?? 'current')}` } export function mergePersistedPart(existing: JsonRecord | undefined, incoming: JsonRecord, delta?: string): JsonRecord { diff --git a/src/web-react/chat-composer.test.tsx b/src/web-react/chat-composer.test.tsx index 9ad0acc..02ebf77 100644 --- a/src/web-react/chat-composer.test.tsx +++ b/src/web-react/chat-composer.test.tsx @@ -105,6 +105,58 @@ describe('ChatComposer', () => { fireEvent.keyDown(document, { key: 'l', metaKey: true }) expect(document.activeElement).toBe(input) }) + + it('emits ready file parts through onSendParts and skips non-ready ones', () => { + const onSendParts = vi.fn() + const readyPart = { type: 'image' as const, filename: 'chart.png', mediaType: 'image/png', url: 'data:image/png;base64,AAAA' } + render( + , + ) + const input = screen.getByLabelText('Message input') + type(input, 'what is this?') + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onSendParts).toHaveBeenCalledExactlyOnceWith('what is this?', [readyPart]) + }) + + it('allows a file-only send when onSendParts is wired', () => { + const onSendParts = vi.fn() + const part = { type: 'file' as const, filename: 'doc.pdf', path: 'uploads/doc.pdf' } + render( + , + ) + const send = screen.getByLabelText('Send') as HTMLButtonElement + expect(send.disabled).toBe(false) + fireEvent.click(send) + expect(onSendParts).toHaveBeenCalledExactlyOnceWith('', [part]) + }) + + it('onSendParts takes precedence over onSend, and onSend keeps working alone', () => { + const onSend = vi.fn() + const onSendParts = vi.fn() + const { rerender } = render() + const input = screen.getByLabelText('Message input') + type(input, 'both wired') + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onSendParts).toHaveBeenCalledExactlyOnceWith('both wired', []) + expect(onSend).not.toHaveBeenCalled() + + rerender() + type(screen.getByLabelText('Message input'), 'legacy path') + fireEvent.keyDown(screen.getByLabelText('Message input'), { key: 'Enter' }) + expect(onSend).toHaveBeenCalledExactlyOnceWith('legacy path') + }) }) function model(partial: Partial & Pick): CatalogModel { diff --git a/src/web-react/chat-composer.tsx b/src/web-react/chat-composer.tsx index f4eca02..8266aca 100644 --- a/src/web-react/chat-composer.tsx +++ b/src/web-react/chat-composer.tsx @@ -77,6 +77,18 @@ function UploadGlyph({ className }: { className?: string }) { // ── component ────────────────────────────────────────────────────────────── +/** Prompt-part descriptor an uploaded file carries (the upload route's + * `UploadedChatFile.part`), echoed back in the turn body on send. Mirrors + * `/chat-routes`' wire shape structurally — no server import here. */ +export interface ComposerFilePart { + type: 'image' | 'file' + filename?: string + mediaType?: string + url?: string + path?: string + content?: string +} + export interface ComposerFile { id: string name: string @@ -85,12 +97,20 @@ export interface ComposerFile { /** Number of files inside, for a folder chip. */ fileCount?: number status: 'pending' | 'uploading' | 'ready' | 'error' + /** Uploaded part descriptor; set once the upload route returns. Only + * `status: 'ready'` files with a part travel on a parts-aware send. */ + part?: ComposerFilePart } export interface ChatComposerProps { /** Send the trimmed, non-empty message. Attached files travel separately via - * `onAttach` + `pendingFiles` (the host consumes and clears them on send). */ - onSend: (message: string) => void + * `onAttach` + `pendingFiles` (the host consumes and clears them on send). + * Optional when `onSendParts` is wired. */ + onSend?: (message: string) => void + /** Parts-aware send: receives the trimmed message plus the `part` + * descriptors of every `ready` pending file. Takes precedence over + * `onSend`; enables file-only sends (empty text, ≥1 ready part). */ + onSendParts?: (message: string, parts: ComposerFilePart[]) => void /** Stop the in-flight turn; shown in place of Send while `isStreaming`. */ onCancel?: () => void isStreaming?: boolean @@ -131,6 +151,7 @@ const MAX_HEIGHT = 168 export function ChatComposer({ onSend, + onSendParts, onCancel, isStreaming = false, disabled = false, @@ -192,14 +213,32 @@ export function ChatComposer({ return () => document.removeEventListener('keydown', onKeyDown) }, [focusShortcut, disabled]) - const canSend = text.trim().length > 0 && !isStreaming && !disabled + const readyParts = pendingFiles + .filter((f) => f.status === 'ready' && f.part) + .map((f) => f.part as ComposerFilePart) + + // Parts-aware sends allow a file-only message (empty text, ≥1 ready part). + const hasSendable = onSendParts + ? text.trim().length > 0 || readyParts.length > 0 + : text.trim().length > 0 + const canSend = hasSendable && !isStreaming && !disabled const send = useCallback(() => { const trimmed = text.trim() - if (!trimmed || isStreaming || disabled) return - onSend(trimmed) + if (isStreaming || disabled) return + if (onSendParts) { + const parts = pendingFiles + .filter((f) => f.status === 'ready' && f.part) + .map((f) => f.part as ComposerFilePart) + if (!trimmed && parts.length === 0) return + onSendParts(trimmed, parts) + setText('') + return + } + if (!trimmed) return + onSend?.(trimmed) setText('') - }, [text, isStreaming, disabled, onSend, setText]) + }, [text, isStreaming, disabled, onSend, onSendParts, pendingFiles, setText]) const handleKeyDown = (e: KeyboardEvent) => { // Respect IME composition — Enter commits the candidate, it doesn't send. diff --git a/src/web-react/chat-stream.ts b/src/web-react/chat-stream.ts index e039139..7ebb596 100644 --- a/src/web-react/chat-stream.ts +++ b/src/web-react/chat-stream.ts @@ -19,6 +19,15 @@ import { type ChatInteraction, } from './chat-interactions' +// The `/chat-routes` wire contract, re-exported for turn-body construction — +// `./chat-routes/wire` is import-free and browser-safe by design. +export { + chatTurnRequestInit, + type ChatTurnFilePartInput, + type ChatTurnPartInput, + type ChatTurnRequestPayload, +} from '../chat-routes/wire' + export interface ChatStreamToolCall { toolCallId?: string toolName: string diff --git a/tests/chat-routes/sandbox-producer.test.ts b/tests/chat-routes/sandbox-producer.test.ts new file mode 100644 index 0000000..3537b15 --- /dev/null +++ b/tests/chat-routes/sandbox-producer.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createSandboxChatProducer } from '../../src/chat-routes/index' + +function partUpdated(part: Record, delta?: string): Record { + return { type: 'message.part.updated', data: { part, ...(delta !== undefined ? { delta } : {}) } } +} + +async function* feed(events: Array>): AsyncGenerator { + for (const event of events) yield event +} + +async function drain(stream: AsyncGenerator<{ type: string }, void, unknown>) { + const out: Array> = [] + for await (const event of stream) out.push(event as Record) + return out +} + +describe('createSandboxChatProducer', () => { + it('maps text deltas, snapshots, reasoning, and result finalText into the client vocabulary', async () => { + const producer = createSandboxChatProducer({ + events: feed([ + partUpdated({ type: 'reasoning', id: 'r1', text: 'thinking' }, 'thinking'), + partUpdated({ type: 'text', id: 'x1', text: 'Hel' }, 'Hel'), + // Snapshot-only update (no delta): suffix must be derived, not re-appended. + partUpdated({ type: 'text', id: 'x1', text: 'Hello' }), + { type: 'result', data: { finalText: 'Hello' } }, + ]), + model: 'anthropic/claude', + }) + const events = await drain(producer.stream) + + expect(events).toEqual([ + { type: 'reasoning', text: 'thinking' }, + { type: 'text', text: 'Hel' }, + { type: 'text', text: 'lo' }, + ]) + expect(producer.finalText()).toBe('Hello') + expect(producer.model).toBe('anthropic/claude') + expect(producer.assistantParts?.()).toEqual([ + expect.objectContaining({ type: 'reasoning', text: 'thinking' }), + expect.objectContaining({ type: 'text', text: 'Hello' }), + ]) + }) + + it('announces a tool once and settles it once, with the persisted tool part tracking state', async () => { + const producer = createSandboxChatProducer({ + events: feed([ + partUpdated({ type: 'tool', id: 'call-1', tool: 'search', state: { status: 'running', input: { q: 'x' } } }), + partUpdated({ type: 'tool', id: 'call-1', tool: 'search', state: { status: 'running', input: { q: 'x' } } }), + partUpdated({ type: 'tool', id: 'call-1', tool: 'search', state: { status: 'completed', input: { q: 'x' }, output: '3 hits' } }), + { type: 'result', data: { finalText: 'done' } }, + ]), + }) + const events = await drain(producer.stream) + + expect(events).toEqual([ + { type: 'tool_call', call: { toolCallId: 'call-1', toolName: 'search', args: { q: 'x' } } }, + { + type: 'tool_result', + toolCallId: 'call-1', + toolName: 'search', + outcome: { ok: true, result: '3 hits' }, + }, + ]) + const parts = producer.assistantParts?.() ?? [] + expect(parts[0]).toMatchObject({ type: 'tool', id: 'call-1', tool: 'search', state: { status: 'completed', output: '3 hits' } }) + }) + + it('accumulates the usage receipt from step-finish parts and emits a usage line', async () => { + const producer = createSandboxChatProducer({ + events: feed([ + partUpdated({ type: 'step-finish', tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 7, write: 3 } }, cost: 0.02 }), + partUpdated({ type: 'step-finish', tokens: { input: 50, output: 10 }, cost: 0.01 }), + ]), + }) + const events = await drain(producer.stream) + + expect(events).toEqual([ + { type: 'usage', usage: { promptTokens: 100, completionTokens: 20 } }, + { type: 'usage', usage: { promptTokens: 150, completionTokens: 30 } }, + ]) + expect(producer.usage?.()).toEqual({ + inputTokens: 150, + outputTokens: 30, + reasoningTokens: 5, + cacheReadTokens: 7, + cacheWriteTokens: 3, + costUsd: 0.03, + }) + // The per-step receipts also persist — one part per step, never merged. + expect(producer.assistantParts?.()).toEqual([ + { type: 'step-finish', tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 7, write: 3 } }, cost: 0.02 }, + { type: 'step-finish', tokens: { input: 50, output: 10 }, cost: 0.01 }, + ]) + }) + + it('persists file/image parts from the stream into the transcript projection', async () => { + const producer = createSandboxChatProducer({ + events: feed([ + partUpdated({ type: 'text', id: 't1', text: 'see the chart' }, 'see the chart'), + partUpdated({ type: 'file', id: 'f1', filename: 'out.csv', mediaType: 'text/csv', path: 'outputs/out.csv' }), + partUpdated({ type: 'image', filename: 'plot.png', mediaType: 'image/png', url: 'data:image/png;base64,AA' }), + { type: 'result', data: { finalText: 'see the chart' } }, + ]), + }) + await drain(producer.stream) + + expect(producer.assistantParts?.()).toEqual([ + expect.objectContaining({ type: 'text', text: 'see the chart' }), + { type: 'file', id: 'f1', filename: 'out.csv', mediaType: 'text/csv', path: 'outputs/out.csv' }, + { type: 'image', filename: 'plot.png', mediaType: 'image/png', url: 'data:image/png;base64,AA' }, + ]) + }) + + it('forwards renderable asks and auto-declines non-renderable ones', async () => { + const declineInteraction = vi.fn(async () => {}) + const ask = (id: string, kind: string) => ({ + type: 'interaction', + data: { + request: { + id, + kind, + title: 'Need input', + answerSpec: { fields: [] }, + }, + }, + }) + const producer = createSandboxChatProducer({ + events: feed([ask('q-1', 'question'), ask('p-1', 'permission')]), + declineInteraction, + log: () => {}, + }) + const events = await drain(producer.stream) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ type: 'interaction', data: { request: { id: 'q-1', kind: 'question' } } }) + expect(declineInteraction).toHaveBeenCalledExactlyOnceWith('p-1') + }) + + it('forwards error and cancel events verbatim and drops malformed interactions', async () => { + const log = vi.fn() + const producer = createSandboxChatProducer({ + events: feed([ + { type: 'interaction', data: {} }, + { type: 'interaction.cancel', data: { id: 'q-1', reason: 'timeout' } }, + { type: 'error', details: 'boom' }, + ]), + log, + }) + const events = await drain(producer.stream) + + expect(events).toEqual([ + { type: 'interaction.cancel', data: { id: 'q-1', reason: 'timeout' } }, + { type: 'error', details: 'boom' }, + ]) + expect(log).toHaveBeenCalledOnce() + }) +}) diff --git a/tests/chat-routes/turn-routes.test.ts b/tests/chat-routes/turn-routes.test.ts new file mode 100644 index 0000000..07afeab --- /dev/null +++ b/tests/chat-routes/turn-routes.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + createChatTurnRoutes, + type ChatTurnMessageStore, + type ChatTurnProduceArgs, + type ChatTurnRouteProducer, +} from '../../src/chat-routes/index' +import type { ChatMessagePart } from '../../src/chat-store/parts' +import type { InteractionRequestWire } from '../../src/interactions/index' +import { createMemoryTurnEventStore } from '../../src/stream/index' + +// ── fakes ──────────────────────────────────────────────────────────────────── + +interface StoredMessage { + id: string + threadId: string + role: 'user' | 'assistant' | 'system' | 'tool' + content: string + parts?: ChatMessagePart[] + model?: string | null + inputTokens?: number | null + outputTokens?: number | null + costUsd?: number | null +} + +function memoryMessageStore() { + const rows: StoredMessage[] = [] + let nextId = 1 + const store: ChatTurnMessageStore = { + async listMessages(threadId) { + return rows.filter((row) => row.threadId === threadId) + }, + async appendMessage(input) { + const row: StoredMessage = { id: `m${nextId++}`, ...input } + rows.push(row) + return row + }, + } + return { store, rows } +} + +/** Producer that streams the given events then reports the final text. */ +function fakeProducer( + events: Array>, + finalText: string, + extras: Partial = {}, +): ChatTurnRouteProducer { + return { + stream: (async function* () { + for (const event of events) yield event as { type: string; data?: Record } + })(), + finalText: () => finalText, + ...extras, + } +} + +function turnRequest(body: Record): Request { + return new Request('http://app.test/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +async function readLines(body: ReadableStream): Promise>> { + const text = await new Response(body).text() + return text + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as Record) +} + +function makeRoutes(overrides: Partial[0]> = {}) { + const { store, rows } = memoryMessageStore() + const turnStore = createMemoryTurnEventStore() + const pending: Promise[] = [] + const ctx = { waitUntil: (p: Promise) => void pending.push(p) } + const routes = createChatTurnRoutes({ + projectId: 'test-app', + authorize: async () => ({ ok: true, tenantId: 'ws-1', userId: 'user-1', context: undefined }), + store, + turnStore, + produce: () => fakeProducer([{ type: 'text', text: 'hi ' }, { type: 'text', text: 'there' }], 'hi there'), + log: () => {}, + ...overrides, + }) + return { routes, rows, turnStore, ctx, pending } +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +describe('createChatTurnRoutes — turn', () => { + it('streams the turn: turn marker first, then engine-framed producer events', async () => { + const { routes, ctx, pending } = makeRoutes() + const res = await routes.turn(turnRequest({ threadId: 't-1', content: 'hello' }), ctx) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('application/x-ndjson') + const lines = await readLines(res.body!) + + expect(lines[0]).toMatchObject({ type: 'turn' }) + expect(typeof lines[0]!.turnId).toBe('string') + const textLines = lines.filter((l) => l.type === 'text') + expect(textLines.map((l) => l.text)).toEqual(['hi ', 'there']) + // The engine owns the lifecycle envelope. + expect(lines.some((l) => String(l.type).startsWith('session.run.'))).toBe(true) + await Promise.all(pending) + }) + + it('persists the user message on send and the assistant message on completion', async () => { + const { routes, rows, ctx, pending } = makeRoutes({ + produce: () => + fakeProducer([{ type: 'text', text: 'answer' }], 'answer', { + assistantParts: () => [{ type: 'text', text: 'answer' }], + usage: () => ({ inputTokens: 11, outputTokens: 7, costUsd: 0.01 }), + model: 'anthropic/claude', + }), + }) + const res = await routes.turn(turnRequest({ threadId: 't-1', content: 'question?' }), ctx) + await readLines(res.body!) + await Promise.all(pending) + + expect(rows).toHaveLength(2) + expect(rows[0]).toMatchObject({ role: 'user', content: 'question?', threadId: 't-1' }) + expect(rows[1]).toMatchObject({ + role: 'assistant', + content: 'answer', + model: 'anthropic/claude', + inputTokens: 11, + outputTokens: 7, + costUsd: 0.01, + }) + expect(rows[1]!.parts).toEqual([{ type: 'text', text: 'answer' }]) + }) + + it('does not double-insert the user row on a retried turnId', async () => { + const { routes, rows, ctx, pending } = makeRoutes() + const body = { threadId: 't-1', content: 'same question', turnId: 'turn-abc' } + await readLines((await routes.turn(turnRequest(body), ctx)).body!) + await Promise.all(pending.splice(0)) + await readLines((await routes.turn(turnRequest(body), ctx)).body!) + await Promise.all(pending.splice(0)) + + expect(rows.filter((r) => r.role === 'user')).toHaveLength(1) + }) + + it('persists echoed file parts onto the user message and hands parts to the producer', async () => { + const produce = vi.fn((_args: ChatTurnProduceArgs) => fakeProducer([{ type: 'text', text: 'ok' }], 'ok')) + const { routes, rows, ctx, pending } = makeRoutes({ produce }) + const filePart = { type: 'image', filename: 'a.png', mediaType: 'image/png', url: 'data:image/png;base64,AAAA' } + const res = await routes.turn( + turnRequest({ threadId: 't-1', content: 'look at this', parts: [filePart] }), + ctx, + ) + await readLines(res.body!) + await Promise.all(pending) + + const userRow = rows.find((r) => r.role === 'user')! + expect(userRow.parts).toEqual([{ type: 'text', text: 'look at this' }, filePart]) + + const args = produce.mock.calls[0]![0] + expect(args.prompt).toEqual([{ type: 'text', text: 'look at this' }, filePart]) + expect(args.identity).toMatchObject({ tenantId: 'ws-1', sessionId: 't-1', userId: 'user-1', turnIndex: 0 }) + expect(args.executionId).toContain('test-app') + }) + + it('rejects a body with neither content nor parts, and a missing threadId', async () => { + const { routes, ctx } = makeRoutes() + expect((await routes.turn(turnRequest({ threadId: 't-1' }), ctx)).status).toBe(400) + expect((await routes.turn(turnRequest({ content: 'x' }), ctx)).status).toBe(400) + }) + + it('rejects inline parts over the byte budget with 413 (gateway-cap gate)', async () => { + const { routes, ctx } = makeRoutes() + const res = await routes.turn( + turnRequest({ + threadId: 't-1', + content: 'big', + parts: [{ type: 'file', filename: 'big.bin', url: `data:application/octet-stream;base64,${'A'.repeat(1_000_001)}` }], + }), + ctx, + ) + expect(res.status).toBe(413) + const body = await res.json() as { code: string } + expect(body.code).toBe('PROMPT_PARTS_TOO_LARGE') + }) + + it('short-circuits with the authorize seam response', async () => { + const { routes, rows, ctx } = makeRoutes({ + authorize: async () => ({ ok: false, response: Response.json({ error: 'nope' }, { status: 401 }) }), + }) + const res = await routes.turn(turnRequest({ threadId: 't-1', content: 'hi' }), ctx) + expect(res.status).toBe(401) + expect(rows).toHaveLength(0) + }) +}) + +describe('createChatTurnRoutes — buffered replay', () => { + it('replays the full turn after a simulated client drop', async () => { + const { routes, ctx, pending } = makeRoutes({ + produce: () => + fakeProducer( + Array.from({ length: 20 }, (_, i) => ({ type: 'text', text: `chunk${i} ` })), + Array.from({ length: 20 }, (_, i) => `chunk${i} `).join(''), + ), + }) + const res = await routes.turn(turnRequest({ threadId: 't-9', content: 'go' }), ctx) + + // Read only the first chunk (the turn marker), then drop the connection. + const reader = res.body!.getReader() + const first = await reader.read() + const firstLine = JSON.parse(new TextDecoder().decode(first.value).split('\n')[0]!) as { turnId: string } + await reader.cancel() + + // The teed drain finishes the turn server-side. + await Promise.all(pending) + + const replayRes = await routes.replay( + new Request(`http://app.test/api/chat/replay/${firstLine.turnId}?fromSeq=0`), + { turnId: firstLine.turnId }, + ) + expect(replayRes.status).toBe(200) + const lines = await readLines(replayRes.body!) + + expect(lines[0]).toMatchObject({ type: 'turn', turnId: firstLine.turnId }) + const textLines = lines.filter((l) => l.type === 'text') + const replayedText = textLines.map((l) => String(l.text)).join('') + expect(replayedText).toBe(Array.from({ length: 20 }, (_, i) => `chunk${i} `).join('')) + // Coalesced persistence: contiguous deltas merge per flush window instead + // of landing as one row per token. + expect(textLines.length).toBeLessThan(20) + // Terminates with the status marker so clients know why the stream ended. + expect(lines.at(-1)).toMatchObject({ type: 'turn_status', status: 'complete' }) + }) + + it('authorizes replay through the same seam', async () => { + const { routes } = makeRoutes({ + authorize: async (args) => + args.intent === 'replay' + ? { ok: false, response: Response.json({ error: 'no' }, { status: 403 }) } + : { ok: true, tenantId: 'ws-1', userId: 'user-1', context: undefined }, + }) + const res = await routes.replay(new Request('http://app.test/replay/x'), { turnId: 'x' }) + expect(res.status).toBe(403) + }) +}) + +describe('createChatTurnRoutes — interactions composition', () => { + function wireQuestion(id: string): InteractionRequestWire { + return { + id, + kind: 'question', + title: 'Proceed?', + answerSpec: { + fields: [{ + type: 'select', + name: 'q0', + label: 'Proceed?', + required: true, + multi: false, + options: [{ value: 'Yes', label: 'Yes' }, { value: 'No', label: 'No' }], + }], + }, + } as InteractionRequestWire + } + + it('answers a sidecar ask round-trip through the composed /interactions route', async () => { + const outstanding = new Map([['ask-1', wireQuestion('ask-1')]]) + const posts: Array> = [] + const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => { + if ((init?.method ?? 'GET') === 'GET') { + return Response.json({ data: { interactions: [...outstanding.values()] } }) + } + const body = JSON.parse(String(init?.body)) as Record + posts.push(body) + outstanding.delete(String(body.id)) + return Response.json({ data: { ok: true } }) + }) as typeof fetch + + const { routes } = makeRoutes({ + interactions: { + resolveConnection: async () => ({ + ok: true, + connection: { runtimeUrl: 'http://sidecar.test', sessionId: 't-1', fetchImpl }, + }), + logger: { warn: () => {}, error: () => {} }, + }, + }) + + expect(routes.interactions).not.toBeNull() + const res = await routes.interactions!.answer( + new Request('http://app.test/api/chat/interactions', { + method: 'POST', + body: JSON.stringify({ id: 'ask-1', outcome: 'accepted', data: { q0: 'Yes' } }), + }), + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + expect(posts).toEqual([{ id: 'ask-1', outcome: 'accepted', data: { q0: 'Yes' } }]) + }) + + it('is null when the product wires no interactions channel', () => { + const { routes } = makeRoutes() + expect(routes.interactions).toBeNull() + }) +}) diff --git a/tests/chat-routes/upload.test.ts b/tests/chat-routes/upload.test.ts new file mode 100644 index 0000000..16957a3 --- /dev/null +++ b/tests/chat-routes/upload.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' + +import { + bytesToBase64, + createUploadRoute, + sanitizeUploadFilename, + type SandboxUploadSink, + type UploadedChatFile, +} from '../../src/chat-routes/index' + +function uploadRequest(files: Array<{ name: string; type: string; bytes: Uint8Array }>): Request { + const form = new FormData() + for (const file of files) { + form.append('files', new File([file.bytes as BlobPart], file.name, { type: file.type })) + } + return new Request('http://app.test/api/chat/upload', { method: 'POST', body: form }) +} + +function recordingSink() { + const writes: Array<{ path: string; content: string; options?: { encoding?: string } }> = [] + const sink: SandboxUploadSink = { + async write(path, content, options) { + writes.push({ path, content, ...(options ? { options } : {}) }) + }, + } + return { sink, writes } +} + +describe('createUploadRoute', () => { + it('returns an inline data-URI part for a small image', async () => { + const bytes = new Uint8Array([137, 80, 78, 71, 13, 10]) + const route = createUploadRoute({ authorize: async () => ({ ok: true }) }) + const res = await route(uploadRequest([{ name: 'chart.png', type: 'image/png', bytes }])) + + expect(res.status).toBe(200) + const { files } = await res.json() as { files: UploadedChatFile[] } + expect(files).toHaveLength(1) + expect(files[0]).toMatchObject({ + name: 'chart.png', + size: bytes.length, + mediaType: 'image/png', + inline: true, + part: { + type: 'image', + filename: 'chart.png', + mediaType: 'image/png', + url: `data:image/png;base64,${bytesToBase64(bytes)}`, + }, + }) + }) + + it('writes a large file to the sandbox as base64 and returns a path-ref part', async () => { + const { sink, writes } = recordingSink() + const bytes = new Uint8Array(64).fill(7) + const route = createUploadRoute({ + authorize: async () => ({ ok: true, sink }), + inlineMaxBytes: 16, // force the sandbox lane + }) + const res = await route(uploadRequest([{ name: 'report.pdf', type: 'application/pdf', bytes }])) + + expect(res.status).toBe(200) + const { files } = await res.json() as { files: UploadedChatFile[] } + expect(files[0]).toMatchObject({ inline: false, mediaType: 'application/pdf' }) + expect(files[0]!.part.type).toBe('file') + expect(files[0]!.part.url).toBeUndefined() + expect(files[0]!.part.path).toMatch(/^uploads\/[0-9a-f-]+-report\.pdf$/) + + expect(writes).toHaveLength(1) + expect(writes[0]).toMatchObject({ + path: files[0]!.part.path, + content: bytesToBase64(bytes), + options: { encoding: 'base64' }, + }) + }) + + it('rejects an over-inline-cap file when no sandbox sink is available', async () => { + const route = createUploadRoute({ + authorize: async () => ({ ok: true }), + inlineMaxBytes: 8, + }) + const res = await route(uploadRequest([{ name: 'big.bin', type: 'application/octet-stream', bytes: new Uint8Array(32) }])) + expect(res.status).toBe(413) + expect(((await res.json()) as { code: string }).code).toBe('SANDBOX_REQUIRED') + }) + + it('rejects a file over the hard per-file cap even with a sink', async () => { + const { sink, writes } = recordingSink() + const route = createUploadRoute({ + authorize: async () => ({ ok: true, sink }), + inlineMaxBytes: 8, + maxFileBytes: 16, + }) + const res = await route(uploadRequest([{ name: 'huge.bin', type: 'application/octet-stream', bytes: new Uint8Array(64) }])) + expect(res.status).toBe(413) + expect(((await res.json()) as { code: string }).code).toBe('FILE_TOO_LARGE') + expect(writes).toHaveLength(0) + }) + + it('short-circuits with the authorize response and rejects empty/non-form bodies', async () => { + const denied = createUploadRoute({ + authorize: async () => ({ ok: false, response: Response.json({ error: 'no' }, { status: 401 }) }), + }) + expect((await denied(uploadRequest([{ name: 'x', type: 'text/plain', bytes: new Uint8Array(1) }]))).status).toBe(401) + + const route = createUploadRoute({ authorize: async () => ({ ok: true }) }) + expect((await route(uploadRequest([]))).status).toBe(400) + const notForm = new Request('http://app.test/upload', { method: 'POST', body: '{"not":"form"}' }) + expect((await route(notForm)).status).toBe(400) + }) + + it('sanitizes hostile filenames into path-safe basenames', () => { + expect(sanitizeUploadFilename('../../etc/passwd')).toBe('passwd') + expect(sanitizeUploadFilename('..\\..\\boot.ini')).toBe('boot.ini') + expect(sanitizeUploadFilename('.hidden')).toBe('_hidden') + expect(sanitizeUploadFilename('spaced name (1).png')).toBe('spaced_name_1_.png') + expect(sanitizeUploadFilename('')).toBe('file') + expect(sanitizeUploadFilename('x'.repeat(300))).toHaveLength(120) + }) + + it('honors the per-request uploadDir override from authorize', async () => { + const { sink, writes } = recordingSink() + const route = createUploadRoute({ + authorize: async () => ({ ok: true, sink, uploadDir: 'workspaces/ws-1/uploads' }), + inlineMaxBytes: 1, + }) + await route(uploadRequest([{ name: 'a.txt', type: 'text/plain', bytes: new Uint8Array(8) }])) + expect(writes[0]!.path.startsWith('workspaces/ws-1/uploads/')).toBe(true) + }) +}) diff --git a/tests/chat-store/parts-projection.test.ts b/tests/chat-store/parts-projection.test.ts new file mode 100644 index 0000000..00c5fd5 --- /dev/null +++ b/tests/chat-store/parts-projection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { toChatMessageParts, type ChatMessagePart } from '../../src/chat-store/parts' + +describe('toChatMessageParts — the /stream → /chat-store typed boundary', () => { + it('accepts every storable kind (nothing storable falls out)', () => { + const oneOfEach: Array> = [ + { type: 'text', text: 'hello', id: 't1' }, + { type: 'reasoning', text: 'hmm' }, + { type: 'tool', id: 'c1', tool: 'search', state: { status: 'completed', output: 'x' } }, + { type: 'file', filename: 'a.csv', path: 'out/a.csv' }, + { type: 'image', url: 'data:image/png;base64,AA' }, + { type: 'subtask', prompt: 'p', description: 'd', agent: 'a' }, + { type: 'step-start' }, + { type: 'step-finish', tokens: { input: 5, output: 2 }, cost: 0.01 }, + { type: 'interaction', id: 'i1', kind: 'question', title: 'T', answerSpec: { fields: [] }, status: 'pending' }, + { type: 'notice', id: 'n1', noticeKind: 'warning', text: 'heads up' }, + ] + const typed: ChatMessagePart[] = toChatMessageParts(oneOfEach) + expect(typed.map((part) => part.type)).toEqual(oneOfEach.map((part) => part.type)) + }) + + it('drops junk: unknown kinds, missing required fields, non-objects', () => { + expect(toChatMessageParts([ + { type: 'telemetry', blob: 1 }, + { type: 'text' }, // no text + { type: 'tool', id: 'c1' }, // no tool/state + { type: 'notice', id: 'n1' }, // no noticeKind/text + { noType: true }, + ])).toEqual([]) + }) + + it('preserves extra fields the row round-trips (e.g. turnId on a user text part)', () => { + const [part] = toChatMessageParts([{ type: 'text', text: 'q', turnId: 'turn-1' }]) + expect(part).toEqual({ type: 'text', text: 'q', turnId: 'turn-1' }) + }) +}) diff --git a/tests/platform.test.ts b/tests/platform.test.ts index 8bb21cf..0872f8e 100644 --- a/tests/platform.test.ts +++ b/tests/platform.test.ts @@ -10,6 +10,7 @@ import { isTangleBearerMissingError, resolveUserTangleHubBearer, resolveUserTangleHubBearerForUser, + guardResolution, TangleBearerMissingError, type HubClientLike, type HubProxyContext, @@ -845,3 +846,19 @@ describe('createBetterAuthSessionCookieMinter', () => { await expect(mint(mintArgs)).rejects.toThrow(/domain-scoped/) }) }) + +describe('guardResolution', () => { + it('adapts a thrown-Response guard to { ok: false, response }', async () => { + const denied = Response.json({ error: 'Unauthorized' }, { status: 401 }) + const result = await guardResolution(async () => { + throw denied + }) + expect(result).toEqual({ ok: false, response: denied }) + }) + + it('wraps a successful guard value and rethrows non-Response errors', async () => { + const session = { user: { id: 'u1' } } + expect(await guardResolution(async () => session)).toEqual({ ok: true, value: session }) + await expect(guardResolution(async () => { throw new Error('db down') })).rejects.toThrow('db down') + }) +}) diff --git a/tests/stream-normalizer.test.ts b/tests/stream-normalizer.test.ts index 0437a3a..177e9e9 100644 --- a/tests/stream-normalizer.test.ts +++ b/tests/stream-normalizer.test.ts @@ -95,6 +95,44 @@ describe('getPartKey', () => { expect(getPartKey({ type: 'text' })).toBe('text:current') expect(getPartKey({ type: 'reasoning' })).toBe('reasoning:current') }) + + it('keys other typed kinds in their own lane, never the text lane', () => { + expect(getPartKey({ type: 'file', id: 'f1' })).toBe('file:f1') + expect(getPartKey({ type: 'image' })).toBe('image:current') + expect(getPartKey({ type: 'step-finish' })).toBe('step-finish:current') + // Untyped legacy parts still fall back to the text lane. + expect(getPartKey({})).toBe('text:current') + }) +}) + +describe('normalizePersistedPart — full storable vocabulary', () => { + it('projects file and image parts (no silent drop)', () => { + expect(normalizePersistedPart({ + type: 'file', id: 'f1', filename: 'a.csv', mediaType: 'text/csv', path: 'out/a.csv', sessionID: 's', messageID: 'm', + })).toEqual({ type: 'file', id: 'f1', filename: 'a.csv', mediaType: 'text/csv', path: 'out/a.csv' }) + expect(normalizePersistedPart({ type: 'image', url: 'data:image/png;base64,AA', mediaType: 'image/png' })) + .toEqual({ type: 'image', mediaType: 'image/png', url: 'data:image/png;base64,AA' }) + }) + + it('keeps the step-finish usage receipt and step-start marker', () => { + expect(normalizePersistedPart({ + type: 'step-finish', reason: 'stop', tokens: { input: 5, output: 2 }, cost: 0.01, + })).toEqual({ type: 'step-finish', reason: 'stop', tokens: { input: 5, output: 2 }, cost: 0.01 }) + expect(normalizePersistedPart({ type: 'step-start', extra: 'stripped' })).toEqual({ type: 'step-start' }) + }) + + it('projects subtask parts and passes system-authored interaction/notice parts through', () => { + expect(normalizePersistedPart({ type: 'subtask', prompt: 'p', description: 'd', agent: 'a', id: 's1' })) + .toEqual({ type: 'subtask', prompt: 'p', description: 'd', agent: 'a', id: 's1' }) + const interaction = { type: 'interaction', id: 'i1', kind: 'question', title: 'T', answerSpec: { fields: [] }, status: 'pending' } + expect(normalizePersistedPart(interaction)).toEqual(interaction) + const notice = { type: 'notice', id: 'n1', noticeKind: 'warning', text: 'heads up' } + expect(normalizePersistedPart(notice)).toEqual(notice) + }) + + it('still returns null for unknown kinds', () => { + expect(normalizePersistedPart({ type: 'telemetry', blob: 1 })).toBeNull() + }) }) describe('mergePersistedPart', () => { diff --git a/tests/vertical/chat-routes-vertical.test.ts b/tests/vertical/chat-routes-vertical.test.ts new file mode 100644 index 0000000..b22dcb3 --- /dev/null +++ b/tests/vertical/chat-routes-vertical.test.ts @@ -0,0 +1,151 @@ +/** + * Vertical scenario for `/chat-routes`: the mini-app's hand-rolled chat route + * replaced by `createChatTurnRoutes` + `createSandboxChatProducer` — the same + * assembled modules (app-auth guard, chat-store persistence, stream + * normalizers, client parser), driven through the factory instead of ~150 + * lines of bespoke pump. Assertions are user-visible outcomes only: what the + * client stream showed, what a later page load reads back, what a replay + * returns after a drop. + */ + +import { describe, expect, it } from 'vitest' + +import { + createChatTurnRoutes, + createSandboxChatProducer, +} from '../../src/chat-routes/index' +import type { ChatStepFinishPart, ChatToolPart } from '../../src/chat-store/index' +import { guardResolution } from '../../src/platform/index' +import { createMemoryTurnEventStore } from '../../src/stream/index' +import { streamChatTurn, type ChatStreamCallbacks } from '../../src/web-react/chat-stream' +import { createMiniApp, MINI_APP_MODEL, type MiniApp } from './mini-app' + +const BASE = 'http://localhost:3000' + +/** Raw sandbox events, as `streamSandboxPrompt` would yield them. */ +const RAW_TURN_EVENTS: Array> = [ + { type: 'message.part.updated', data: { part: { type: 'reasoning', id: 'r1', text: 'checking the vault' }, delta: 'checking the vault' } }, + { type: 'message.part.updated', data: { part: { type: 'text', id: 'txt1', text: 'Filed ' }, delta: 'Filed ' } }, + { type: 'message.part.updated', data: { part: { type: 'tool', id: 'call-1', tool: 'vault_search', state: { status: 'running', input: { query: 'lease' } } } } }, + { type: 'message.part.updated', data: { part: { type: 'tool', id: 'call-1', tool: 'vault_search', state: { status: 'completed', input: { query: 'lease' }, output: { hits: 2 } } } } }, + { type: 'message.part.updated', data: { part: { type: 'text', id: 'txt1', text: 'Filed the lease summary.' }, delta: 'the lease summary.' } }, + { type: 'message.part.updated', data: { part: { type: 'step-finish', reason: 'stop', tokens: { input: 40, output: 20, reasoning: 5, cache: { read: 10, write: 2 } }, cost: 0.0123 } } }, + { type: 'result', data: { finalText: 'Filed the lease summary.' } }, +] + +async function* feed(events: Array>): AsyncGenerator { + for (const event of events) yield event +} + +function factoryRoutes(app: MiniApp, turnStore = createMemoryTurnEventStore()) { + const pending: Promise[] = [] + const routes = createChatTurnRoutes({ + projectId: 'vertical-mini', + authorize: async ({ request }) => { + const auth = await guardResolution(() => app.appAuth.requireApiUser(request)) + if (!auth.ok) return auth + const { user } = auth.value + return { ok: true as const, tenantId: 'ws1', userId: user.id, context: undefined } + }, + store: app.store, + turnStore, + produce: () => createSandboxChatProducer({ events: feed(RAW_TURN_EVENTS), model: MINI_APP_MODEL }), + log: () => {}, + }) + const ctx = { waitUntil: (p: Promise) => void pending.push(p) } + return { routes, ctx, settle: () => Promise.all(pending) } +} + +describe('vertical: createChatTurnRoutes replaces the hand-rolled chat route', () => { + it('runs the full turn through the factory: client stream, persisted rows, receipt columns, replay after the fact', async () => { + const app = await createMiniApp() + await app.createWorkspace('ws1') + const cookie = await app.signUp('factory@example.com') + app.grantMembership('factory@example.com', 'ws1') + const thread = await app.store.createThread({ workspaceId: 'ws1', firstMessage: 'File my lease summary' }) + + const { routes, ctx, settle } = factoryRoutes(app) + + const log: Array<[string, unknown]> = [] + let turnId: string | null = null + const cb: ChatStreamCallbacks = { + onTurnId: (id) => { turnId = id }, + 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]), + onErrorEvent: (m) => log.push(['error', m]), + } + const result = await streamChatTurn({ + start: () => routes.turn( + new Request(`${BASE}/api/chat`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ threadId: thread.id, content: 'File my lease summary' }), + }), + ctx, + ), + resume: (id, fromSeq) => routes.replay(new Request(`${BASE}/api/chat/replay/${id}?fromSeq=${fromSeq}`, { headers: { cookie } }), { turnId: id }), + callbacks: cb, + }) + await settle() + + // The client saw the whole vocabulary the mini-app's bespoke route emits. + expect(result.receivedContent).toBe(true) + expect(turnId).toBeTruthy() + expect(log.filter(([k]) => k === 'text').map(([, v]) => v).join('')).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.filter(([k]) => k === 'error')).toEqual([]) + + // A later page load reads back both rows with typed parts + the receipt. + const messages = await app.store.listMessages(thread.id) + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant']) + 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 = assistant.parts ?? [] + const tool = parts.find((p): p is ChatToolPart => p.type === 'tool') + expect(tool).toMatchObject({ tool: 'vault_search', state: { status: 'completed' } }) + const receipt = parts.find((p): p is ChatStepFinishPart => p.type === 'step-finish') + expect(receipt?.tokens).toMatchObject({ input: 40, output: 20 }) + + // The buffered turn replays in full after the live stream is long gone. + const replayRes = await routes.replay( + new Request(`${BASE}/api/chat/replay/${turnId}?fromSeq=0`, { headers: { cookie } }), + { turnId: turnId! }, + ) + const replayText = await new Response(replayRes.body).text() + const replayLines = replayText.split('\n').filter(Boolean).map((l) => JSON.parse(l) as Record) + expect(replayLines.filter((l) => l.type === 'text').map((l) => String(l.text)).join('')).toBe('Filed the lease summary.') + expect(replayLines.at(-1)).toMatchObject({ type: 'turn_status', status: 'complete' }) + }) + + it('rejects an unauthenticated turn with the guard 401 contract, before any row is written', async () => { + const app = await createMiniApp() + await app.createWorkspace('ws1') + const thread = await app.store.createThread({ workspaceId: 'ws1', firstMessage: 'seed' }) + const { routes, ctx } = factoryRoutes(app) + + const res = await routes.turn( + new Request(`${BASE}/api/chat`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ threadId: thread.id, content: 'hi' }), + }), + ctx, + ) + expect(res.status).toBe(401) + expect(await res.json()).toMatchObject({ code: 'auth.unauthenticated' }) + expect(await app.store.listMessages(thread.id)).toEqual([]) + }) +}) diff --git a/tests/vertical/mini-app.ts b/tests/vertical/mini-app.ts index 119e1ad..2dea6f2 100644 --- a/tests/vertical/mini-app.ts +++ b/tests/vertical/mini-app.ts @@ -210,30 +210,31 @@ export async function createMiniApp(options: MiniAppOptions = {}): Promise