diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 4e13c07..733f60d 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -13,6 +13,7 @@ import { type TokenOverride, } from '@anarchitecture/summon/engine'; import { + createProtocolLineWriter, resolveSurfaceGenerationPlan, runSurfaceGeneration, summarizeContractIssues, @@ -436,8 +437,8 @@ app.get('/api/ghost-roots', (_req, res) => { }); /** - * Streams LLM output as raw text — the client parses JSONL out of it. Each - * completed newline-terminated line should be one protocol message. + * Streams hardened Summon JSONL. Each completed newline-terminated line is one + * protocol message that has passed through the server generation lifecycle. */ app.post('/api/generate', async (req, res) => { const prompt = typeof req.body?.prompt === 'string' ? req.body.prompt.trim() : ''; @@ -657,6 +658,16 @@ app.post('/api/generate', async (req, res) => { }); } + const responseAbort = new AbortController(); + res.once('close', () => { + if (!res.writableEnded) { + responseAbort.abort(new Error('client disconnected')); + } + }); + const writeProtocolLine = createProtocolLineWriter(res, { + signal: responseAbort.signal, + }); + await withConcurrencyCap(async () => { try { let usage: AnthropicUsageSnapshot | null = null; @@ -689,12 +700,11 @@ app.post('/api/generate', async (req, res) => { activeTokensCss: ghostContext?.tokenSource.css ?? direction?.tokensCss ?? null, preludeLines, repair, + signal: responseAbort.signal, modelProvider: (request) => streamAnthropicGeneration(request, (nextUsage) => { usage = nextUsage; }), - }, (line) => { - res.write(`${JSON.stringify(line)}\n`); - }); + }, writeProtocolLine); if (ghostContext) { const reviewLine: ProtocolLine = { @@ -709,7 +719,7 @@ app.post('/api/generate', async (req, res) => { prompt, }), }; - res.write(`${JSON.stringify(reviewLine)}\n`); + await writeProtocolLine(reviewLine); } const finalUsage = usage ?? { input_tokens: 0, @@ -736,12 +746,18 @@ app.post('/api/generate', async (req, res) => { ` cache_read=${finalUsage.cache_read_input_tokens ?? 0}` + ` cache_write=${finalUsage.cache_creation_input_tokens ?? 0}` ); - res.end(); + if (!res.writableEnded && !res.destroyed) res.end(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error('[generate] error:', msg); - res.write(`${JSON.stringify({ op: 'meta', path: '/error', value: msg } satisfies ProtocolLine)}\n`); - res.end(); + if (!res.writableEnded && !res.destroyed) { + try { + await writeProtocolLine({ op: 'meta', path: '/error', value: msg }); + } catch { + // Response closed while reporting the error. + } + } + if (!res.writableEnded && !res.destroyed) res.end(); } }); }); diff --git a/docs/adoption/integration.md b/docs/adoption/integration.md index d0746bf..b3065d8 100644 --- a/docs/adoption/integration.md +++ b/docs/adoption/integration.md @@ -146,6 +146,7 @@ streamed JSONL, optionally retry invalid sections, and emit diagnostics. ```ts import { + createProtocolLineWriter, runSurfaceGeneration, type SummonModelProvider, } from '@anarchitecture/summon-server'; @@ -156,6 +157,11 @@ const modelProvider: SummonModelProvider = async function* ({ prompt, promptBloc yield* callYourModel({ prompt, promptBlocks }); }; +const abortController = new AbortController(); +// Wire this to your HTTP request/response close handling. +const signal = abortController.signal; +const writeProtocolLine = createProtocolLineWriter(response, { signal }); + await runSurfaceGeneration({ prompt, modelProvider, @@ -170,11 +176,13 @@ await runSurfaceGeneration({ preludeLines: [ { op: 'meta', path: '/shape', value: shape }, ], -}, (line) => { - response.write(`${JSON.stringify(line)}\n`); -}); + signal, +}, writeProtocolLine); ``` +`createProtocolLineWriter()` serializes accepted Summon protocol lines as JSONL +and waits for writable backpressure before generation continues. + To enable validation retries, pass `repair: { enabled: true, provider, maxAttempts, maxTargets }`. The provider receives the compiled prompt blocks and a single replacement prompt; return one @@ -183,8 +191,11 @@ replacement JSONL line for the same section path. ## 5. Render In The Sandbox The client should let `@anarchitecture/summon` own chunk decoding, protocol -parsing, stream diagnostics, and render timing. Product hosts still own -fetching, aborts, request payloads, and product-specific meta interpretation. +parsing, stream diagnostics, and render timing for Summon-hardened JSONL +streams. Do not point `consumeSurfaceStream()` directly at raw model output; +the server runner is responsible for validation and hardening. Product hosts +still own fetching, aborts, request payloads, and product-specific meta +interpretation. ```ts import { compileSurfacePolicy } from '@anarchitecture/summon'; diff --git a/docs/adoption/package-consumption.md b/docs/adoption/package-consumption.md index 66cb405..1581c2a 100644 --- a/docs/adoption/package-consumption.md +++ b/docs/adoption/package-consumption.md @@ -107,10 +107,11 @@ import { } from '@anarchitecture/summon/assets'; ``` -Use `consumeSurfaceStream()` to decode streamed chunks, parse accepted protocol -lines, maintain generated HTML, update stream diagnostics, and render through -the sandbox handle. Spawn the iframe with allowed host tools from host-owned -contracts. +Use `consumeSurfaceStream()` to decode Summon-hardened streamed chunks, parse +accepted protocol lines, maintain generated HTML, update stream diagnostics, +and render through the sandbox handle. Do not use it as a direct raw-model +parser; server-side hardening belongs in `runSurfaceGeneration()`. Spawn the +iframe with allowed host tools from host-owned contracts. `compileSurfacePolicy(surfacePolicy, catalogs)` gives the client the stream mode and narrowed contracts that the server will enforce. Generation authority @@ -188,6 +189,7 @@ await consumeSurfaceStream(response.body!, { ```ts import { + createProtocolLineWriter, runSurfaceGeneration, type SummonModelProvider, } from '@anarchitecture/summon-server'; @@ -197,10 +199,14 @@ import { prompt blocks and returns text chunks. The runner applies the surface config, validates streamed JSONL, optionally runs targeted validation retries, emits accepted Summon lines and diagnostics, and returns a replay summary. +Use `createProtocolLineWriter()` when writing the stream to an HTTP response so +the server waits for writable backpressure and aborts cleanly when the response +signal aborts. `generateSurfaceStream()` remains available for existing integrations that consume an async generator, but new servers should prefer -`runSurfaceGeneration(input, emit)`. +`runSurfaceGeneration(input, emit)`. The generator compatibility path buffers +lines internally, so it is less suitable for high-throughput HTTP serving. ## Package Gate diff --git a/packages/host/src/surface-stream.ts b/packages/host/src/surface-stream.ts index dd1d1e5..db4fb9f 100644 --- a/packages/host/src/surface-stream.ts +++ b/packages/host/src/surface-stream.ts @@ -43,6 +43,7 @@ export interface SurfaceStreamOptions { accumulator?: SectionAccumulator; streamGraph?: StreamGraph; renderMode?: SurfaceStreamRenderMode; + cancelOnStop?: boolean; shouldApplyLine?: ( line: ProtocolLine, context: SurfaceStreamContext, @@ -98,6 +99,7 @@ export async function consumeSurfaceStream( let acceptedStructuralLines = 0; let stopped = false; let discarded = false; + const shouldCancelSource = () => stopped && options.cancelOnStop !== false; const context = ( raw?: string, @@ -178,7 +180,7 @@ export async function consumeSurfaceStream( }; try { - for await (const chunk of chunksFromSource(source)) { + for await (const chunk of chunksFromSource(source, shouldCancelSource)) { if (stopped) break; buffer += decodeChunk(chunk, decoder); let nl = buffer.indexOf('\n'); @@ -188,6 +190,7 @@ export async function consumeSurfaceStream( if (stopped) break; nl = buffer.indexOf('\n'); } + if (stopped) break; } if (!stopped) buffer += decoder.decode(); @@ -219,6 +222,7 @@ export async function consumeSurfaceStream( async function* chunksFromSource( source: SurfaceStreamSource, + shouldCancel: () => boolean, ): AsyncGenerator { if (isReadableStream(source)) { const reader = source.getReader(); @@ -229,17 +233,42 @@ async function* chunksFromSource( if (value !== undefined) yield value; } } finally { + if (shouldCancel()) { + await reader.cancel().catch(() => {}); + } reader.releaseLock(); } return; } if (isAsyncIterable(source)) { - for await (const chunk of source) yield chunk; + const iterator = source[Symbol.asyncIterator](); + try { + while (true) { + const next = await iterator.next(); + if (next.done) return; + yield next.value; + } + } finally { + if (shouldCancel()) { + await iterator.return?.(); + } + } return; } - for (const chunk of source) yield chunk; + const iterator = source[Symbol.iterator](); + try { + while (true) { + const next = iterator.next(); + if (next.done) return; + yield next.value; + } + } finally { + if (shouldCancel()) { + iterator.return?.(); + } + } } function decodeChunk(chunk: SurfaceStreamChunk, decoder: TextDecoder): string { diff --git a/packages/host/test/surface-stream.test.ts b/packages/host/test/surface-stream.test.ts index 226c5df..7be35b7 100644 --- a/packages/host/test/surface-stream.test.ts +++ b/packages/host/test/surface-stream.test.ts @@ -213,6 +213,86 @@ test('consumeSurfaceStream can stop before applying a line', async () => { assert.equal(result.html, ''); }); +test('consumeSurfaceStream cancels a ReadableStream when stop is returned', async () => { + let canceled = false; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode( + '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n' + + '{"op":"add","path":"/section/hero","html":"

Stop

"}\n' + + '{"op":"add","path":"/section/hero","html":"

Ignored

"}\n', + )); + }, + cancel() { + canceled = true; + }, + }); + + const result = await consumeSurfaceStream(source, { + mode: 'static', + shouldApplyLine: (line) => line.op === 'add' ? 'stop' : 'apply', + }); + + assert.equal(result.stopped, true); + assert.equal(canceled, true); +}); + +test('consumeSurfaceStream can preserve source when cancelOnStop is false', async () => { + let canceled = false; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode( + '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n' + + '{"op":"add","path":"/section/hero","html":"

Stop

"}\n', + )); + }, + cancel() { + canceled = true; + }, + }); + + const result = await consumeSurfaceStream(source, { + mode: 'static', + cancelOnStop: false, + shouldApplyLine: (line) => line.op === 'add' ? 'stop' : 'apply', + }); + + assert.equal(result.stopped, true); + assert.equal(canceled, false); +}); + +test('consumeSurfaceStream calls async iterator return when stop is returned', async () => { + let returned = false; + const chunks = [ + '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n' + + '{"op":"add","path":"/section/hero","html":"

Stop

"}\n', + '{"op":"add","path":"/section/hero","html":"

Ignored

"}\n', + ]; + const source: AsyncIterable = { + [Symbol.asyncIterator]() { + let index = 0; + return { + async next() { + if (index >= chunks.length) return { done: true, value: undefined }; + return { done: false, value: chunks[index++]! }; + }, + async return() { + returned = true; + return { done: true, value: undefined }; + }, + }; + }, + }; + + const result = await consumeSurfaceStream(source, { + mode: 'static', + shouldApplyLine: (line) => line.op === 'add' ? 'stop' : 'apply', + }); + + assert.equal(result.stopped, true); + assert.equal(returned, true); +}); + test('consumeSurfaceStream can use supplied accumulator and graph instances', async () => { const accumulator = new SectionAccumulator(); const streamGraph = new StreamGraph(); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 5eb5d8d..daa8c6f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,6 +1,7 @@ export { generateSurfaceStream } from './compat.js'; export { buildEditBlock } from './edit.js'; export { resolveSurfaceGenerationPlan } from './plan.js'; +export { createProtocolLineWriter } from './protocol-line-writer.js'; export { runSurfaceGeneration } from './runner.js'; export { summarizeContractIssues } from './summary.js'; @@ -21,6 +22,11 @@ export type { SurfaceGenerationSummary, } from './types.js'; +export type { + ProtocolLineWritableTarget, + ProtocolLineWriterOptions, +} from './protocol-line-writer.js'; + export type { ContractIssue, ContractPromptBlock, diff --git a/packages/server/src/protocol-line-writer.ts b/packages/server/src/protocol-line-writer.ts new file mode 100644 index 0000000..5567daa --- /dev/null +++ b/packages/server/src/protocol-line-writer.ts @@ -0,0 +1,127 @@ +import type { ProtocolLine } from '@summon-internal/engine'; + +type WritableEvent = 'drain' | 'error' | 'close'; +type WritableListener = (...args: unknown[]) => void; + +export interface ProtocolLineWritableTarget { + write(chunk: string): boolean; + once(event: WritableEvent, listener: WritableListener): unknown; + off?(event: WritableEvent, listener: WritableListener): unknown; + removeListener?(event: WritableEvent, listener: WritableListener): unknown; + writableEnded?: boolean; + destroyed?: boolean; +} + +export interface ProtocolLineWriterOptions { + signal?: AbortSignal; +} + +export function createProtocolLineWriter( + target: ProtocolLineWritableTarget, + options: ProtocolLineWriterOptions = {}, +): (line: ProtocolLine) => Promise { + let queue = Promise.resolve(); + + return (line) => { + const payload = `${JSON.stringify(line)}\n`; + const write = queue.then(() => writePayload(target, payload, options.signal)); + queue = write.catch(() => {}); + return write; + }; +} + +async function writePayload( + target: ProtocolLineWritableTarget, + payload: string, + signal: AbortSignal | undefined, +): Promise { + assertWritable(target, signal); + let acceptsMore = false; + try { + acceptsMore = target.write(payload); + } catch (err) { + throw err instanceof Error ? err : new Error(String(err)); + } + assertWritable(target, signal); + if (acceptsMore) return; + await waitForDrain(target, signal); +} + +function waitForDrain( + target: ProtocolLineWritableTarget, + signal: AbortSignal | undefined, +): Promise { + return new Promise((resolve, reject) => { + const cleanup = () => { + removeListener(target, 'drain', onDrain); + removeListener(target, 'error', onError); + removeListener(target, 'close', onClose); + signal?.removeEventListener('abort', onAbort); + }; + const finish = (fn: () => void) => { + cleanup(); + fn(); + }; + const onDrain = () => finish(resolve); + const onError = (err: unknown) => finish(() => { + reject(err instanceof Error ? err : new Error(String(err))); + }); + const onClose = () => finish(() => { + reject(new Error('Protocol line writable closed before drain')); + }); + const onAbort = () => finish(() => { + reject(abortError(signal)); + }); + + if (signal?.aborted) { + reject(abortError(signal)); + return; + } + if (target.destroyed || target.writableEnded) { + reject(new Error('Protocol line writable is closed')); + return; + } + + target.once('drain', onDrain); + target.once('error', onError); + target.once('close', onClose); + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) { + onAbort(); + return; + } + if (target.destroyed || target.writableEnded) { + onClose(); + } + }); +} + +function assertWritable( + target: ProtocolLineWritableTarget, + signal: AbortSignal | undefined, +): void { + if (signal?.aborted) throw abortError(signal); + if (target.destroyed || target.writableEnded) { + throw new Error('Protocol line writable is closed'); + } +} + +function removeListener( + target: ProtocolLineWritableTarget, + event: WritableEvent, + listener: WritableListener, +): void { + if (target.off) { + target.off(event, listener); + return; + } + target.removeListener?.(event, listener); +} + +function abortError(signal: AbortSignal | undefined): Error { + const reason = signal?.reason; + if (reason instanceof Error) return reason; + const error = new Error('Protocol line write aborted'); + error.name = 'AbortError'; + return error; +} diff --git a/packages/server/test/generate-surface-stream.test.ts b/packages/server/test/generate-surface-stream.test.ts index 689c256..e1564af 100644 --- a/packages/server/test/generate-surface-stream.test.ts +++ b/packages/server/test/generate-surface-stream.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; import test from 'node:test'; import { + createProtocolLineWriter, generateSurfaceStream, resolveSurfaceGenerationPlan, runSurfaceGeneration, @@ -10,6 +12,21 @@ import { type SummonModelProvider, } from '../src/index.ts'; +class FakeWritable extends EventEmitter { + readonly writes: string[] = []; + writableEnded = false; + destroyed = false; + + constructor(private readonly writeResults: boolean[] = []) { + super(); + } + + write(chunk: string): boolean { + this.writes.push(chunk); + return this.writeResults.shift() ?? true; + } +} + const surfacePlan = { purpose: 'inform', runtime: 'static', @@ -28,6 +45,82 @@ async function collectGenerator(stream: AsyncGenerator { + const target = new FakeWritable([true]); + const writer = createProtocolLineWriter(target); + const line: ProtocolLine = { op: 'meta', path: '/status', value: 'ok' }; + + await writer(line); + + assert.deepEqual(target.writes, [`${JSON.stringify(line)}\n`]); +}); + +test('createProtocolLineWriter waits for drain when write returns false', async () => { + const target = new FakeWritable([false]); + const writer = createProtocolLineWriter(target); + let settled = false; + + const pending = writer({ op: 'meta', path: '/status', value: 'waiting' }).then(() => { + settled = true; + }); + await Promise.resolve(); + assert.equal(settled, false); + + target.emit('drain'); + await pending; + assert.equal(settled, true); +}); + +test('createProtocolLineWriter preserves write order across backpressure', async () => { + const target = new FakeWritable([false, true]); + const writer = createProtocolLineWriter(target); + const first: ProtocolLine = { op: 'set', path: '/screen', value: { sections: ['hero'] } }; + const second: ProtocolLine = { op: 'add', path: '/section/hero', html: '

Hello

' }; + + const firstWrite = writer(first); + const secondWrite = writer(second); + await Promise.resolve(); + assert.deepEqual(target.writes, [`${JSON.stringify(first)}\n`]); + + target.emit('drain'); + await Promise.all([firstWrite, secondWrite]); + assert.deepEqual(target.writes, [ + `${JSON.stringify(first)}\n`, + `${JSON.stringify(second)}\n`, + ]); +}); + +test('createProtocolLineWriter rejects on writable error, closed target, and abort', async () => { + const errorTarget = new FakeWritable([false]); + const errorWriter = createProtocolLineWriter(errorTarget); + const errored = errorWriter({ op: 'meta', path: '/status', value: 'wait' }); + await Promise.resolve(); + errorTarget.emit('error', new Error('boom')); + await assert.rejects(errored, /boom/); + + const closedTarget = new FakeWritable(); + closedTarget.writableEnded = true; + await assert.rejects( + createProtocolLineWriter(closedTarget)({ op: 'meta', path: '/status', value: 'closed' }), + /closed/, + ); + + const destroyedTarget = new FakeWritable(); + destroyedTarget.destroyed = true; + await assert.rejects( + createProtocolLineWriter(destroyedTarget)({ op: 'meta', path: '/status', value: 'destroyed' }), + /closed/, + ); + + const abortTarget = new FakeWritable([false]); + const controller = new AbortController(); + const aborted = createProtocolLineWriter(abortTarget, { + signal: controller.signal, + })({ op: 'meta', path: '/status', value: 'wait' }); + controller.abort(new Error('gone')); + await assert.rejects(aborted, /gone/); +}); + test('generateSurfaceStream hardens provider JSONL and returns replay summary', async () => { const provider: SummonModelProvider = async function* () { yield '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n'; @@ -47,6 +140,25 @@ test('generateSurfaceStream hardens provider JSONL and returns replay summary', assert.equal(summary.streamGraph.sections.length, 1); }); +test('runSurfaceGeneration can emit through createProtocolLineWriter', async () => { + const target = new FakeWritable(); + const writer = createProtocolLineWriter(target); + + const summary = await runSurfaceGeneration({ + prompt: 'hello', + modelProvider: async function* () { + yield '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n'; + yield '{"op":"add","path":"/section/hero","html":"

Hello

"}\n'; + }, + mode: 'static', + }, writer); + + const lines = target.writes.map((raw) => JSON.parse(raw) as ProtocolLine); + assert.equal(summary.blocked, false); + assert.deepEqual(lines.slice(0, 2).map((line) => line.path), ['/screen', '/section/hero']); + assert.equal(lines.at(-1)?.path, '/stream-graph-summary'); +}); + test('generateSurfaceStream blocks unsafe sections', async () => { const provider: SummonModelProvider = async function* () { yield '{"op":"add","path":"/section/hero","html":""}\n'; diff --git a/packages/summon-server/src/index.ts b/packages/summon-server/src/index.ts index 2eed851..f503dc3 100644 --- a/packages/summon-server/src/index.ts +++ b/packages/summon-server/src/index.ts @@ -1,4 +1,5 @@ export { + createProtocolLineWriter, generateSurfaceStream, resolveSurfaceGenerationPlan, runSurfaceGeneration, @@ -11,6 +12,8 @@ export type { GenerateSurfaceInput, GenerationSummary, ProtocolLine, + ProtocolLineWritableTarget, + ProtocolLineWriterOptions, ProtocolSkipMetaValue, RepairFeedbackMetaValue, RepairOptions, diff --git a/scripts/build-public-packages.mjs b/scripts/build-public-packages.mjs index eb55a74..88feff4 100644 --- a/scripts/build-public-packages.mjs +++ b/scripts/build-public-packages.mjs @@ -453,6 +453,7 @@ const serverExports = { '.': { values: { './_internal/server/index.js': [ + 'createProtocolLineWriter', 'generateSurfaceStream', 'resolveSurfaceGenerationPlan', 'runSurfaceGeneration', @@ -467,6 +468,8 @@ const serverExports = { 'GenerateSurfaceInput', 'GenerationSummary', 'ProtocolLine', + 'ProtocolLineWritableTarget', + 'ProtocolLineWriterOptions', 'ProtocolSkipMetaValue', 'RepairFeedbackMetaValue', 'RepairOptions', diff --git a/scripts/check-public-api.mjs b/scripts/check-public-api.mjs index ae2741e..95aeab2 100644 --- a/scripts/check-public-api.mjs +++ b/scripts/check-public-api.mjs @@ -25,6 +25,7 @@ const expectedRootExports = [ ].sort(); const expectedServerExports = [ + 'createProtocolLineWriter', 'generateSurfaceStream', 'resolveSurfaceGenerationPlan', 'runSurfaceGeneration', diff --git a/scripts/public-api-manifest.json b/scripts/public-api-manifest.json index c497795..5b37746 100644 --- a/scripts/public-api-manifest.json +++ b/scripts/public-api-manifest.json @@ -410,6 +410,7 @@ ".": { "file": "index", "values": [ + "createProtocolLineWriter", "generateSurfaceStream", "resolveSurfaceGenerationPlan", "runSurfaceGeneration", @@ -422,6 +423,8 @@ "GenerateSurfaceInput", "GenerationSummary", "ProtocolLine", + "ProtocolLineWritableTarget", + "ProtocolLineWriterOptions", "ProtocolSkipMetaValue", "RepairFeedbackMetaValue", "RepairOptions", diff --git a/scripts/smoke-public-packages.mjs b/scripts/smoke-public-packages.mjs index 354601b..16e2d1b 100644 --- a/scripts/smoke-public-packages.mjs +++ b/scripts/smoke-public-packages.mjs @@ -69,7 +69,7 @@ await writeFile(join(projectDir, 'smoke.mjs'), [ "import { createSurfaceEnvelope, parseSurfaceEnvelope } from '@anarchitecture/summon/envelope';", "import { bootstrapSource, tokensSource } from '@anarchitecture/summon/assets';", "import { createEventStore } from '@anarchitecture/summon/devtools';", - "import { runSurfaceGeneration, generateSurfaceStream, resolveSurfaceGenerationPlan, summarizeContractIssues } from '@anarchitecture/summon-server';", + "import { createProtocolLineWriter, runSurfaceGeneration, generateSurfaceStream, resolveSurfaceGenerationPlan, summarizeContractIssues } from '@anarchitecture/summon-server';", "import { SummonSurface, defineReactComponent } from '@anarchitecture/summon-react';", "", "const root = await import('@anarchitecture/summon');", @@ -87,7 +87,7 @@ await writeFile(join(projectDir, 'smoke.mjs'), [ "if (typeof createSurfaceEnvelope !== 'function' || typeof parseSurfaceEnvelope !== 'function') throw new Error('envelope import failed');", "if (typeof bootstrapSource !== 'string' || typeof tokensSource !== 'string') throw new Error('assets import failed');", "if (typeof createEventStore !== 'function') throw new Error('devtools import failed');", - "if (typeof runSurfaceGeneration !== 'function' || typeof generateSurfaceStream !== 'function' || typeof resolveSurfaceGenerationPlan !== 'function' || typeof summarizeContractIssues !== 'function') throw new Error('server import failed');", + "if (typeof createProtocolLineWriter !== 'function' || typeof runSurfaceGeneration !== 'function' || typeof generateSurfaceStream !== 'function' || typeof resolveSurfaceGenerationPlan !== 'function' || typeof summarizeContractIssues !== 'function') throw new Error('server import failed');", "if (typeof SummonSurface !== 'function' || typeof defineReactComponent !== 'function') throw new Error('react import failed');", "for (const forbidden of ['spawnSandbox', 'compileSystemContracts', 'buildCapabilitiesBlock', 'deriveSurfacePlanControls', 'parseProtocolLine', 'StreamGraph']) {", " if (forbidden in root) throw new Error(`root leaked ${forbidden}`);",