import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Schema, Stream } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; import { createServer, type ServerResponse } from "node:http"; import type { AddressInfo, Socket } from "node:net"; import { createExecutor, AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, } from "@executor-js/sdk"; import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; import { makeOpenApiHttpApiTestIntegrationConfig, unwrapInvocation } from "../testing"; import { collectStreamingBody, STREAM_MAX_BYTES } from "./invoke"; import { openApiPlugin } from "./plugin"; const testPlugins = () => [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; const STREAM_TOOL = "logs.getLogs"; const LogsGroup = HttpApiGroup.make("logs").add( HttpApiEndpoint.get("getLogs", "/logs", { success: Schema.Unknown, }), ); const StreamingApi = HttpApi.make("streamingResponseTest") .add(LogsGroup) .annotateMerge(OpenApi.annotations({ title: "StreamingResponseTest", version: "1.0.0" })); const buildExecutor = (baseUrl: string) => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); yield* executor.openapi.addSpec( makeOpenApiHttpApiTestIntegrationConfig(StreamingApi, { slug: "streaming", baseUrl }), ); yield* executor.connections.create({ owner: "org", name: ConnectionName.make("main"), integration: IntegrationSlug.make("streaming"), template: AuthTemplateSlug.make("apiKey"), value: "token", }); return { executor, address: ToolAddress.make(`tools.streaming.org.main.${STREAM_TOOL}`), }; }); const startRawServer = (handler: (res: ServerResponse) => void) => Effect.acquireRelease( Effect.callback<{ baseUrl: string; close: () => void }>((resume) => { const sockets = new Set(); const server = createServer((req, res) => { if (req.url?.split("?")[0] !== "/logs") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); return; } handler(res); }); server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); }); server.listen(0, "127.0.0.1", () => { const port = (server.address() as AddressInfo).port; resume( Effect.succeed({ baseUrl: `http://127.0.0.1:${port}`, close: () => { for (const socket of sockets) socket.destroy(); server.close(); }, }), ); }); }), (server) => Effect.sync(() => server.close()), ); const encoder = new TextEncoder(); const delayedReadableStream = ( chunks: readonly Uint8Array[], delayMs: number, options: { close: boolean }, ) => Stream.fromReadableStream({ evaluate: () => { const timers: ReturnType[] = []; return new ReadableStream({ start: (controller) => { chunks.forEach((chunk, index) => { timers.push( setTimeout(() => { controller.enqueue(chunk); if (options.close && index === chunks.length - 1) { controller.close(); } }, index * delayMs), ); }); }, cancel: () => { for (const timer of timers) clearTimeout(timer); }, }); }, onError: (error) => error, }); describe("OpenAPI streaming responses", () => { it.effect("returns from an oversized NDJSON stream with truncation metadata", () => Effect.gen(function* () { const rows = Array.from( { length: 6 }, (_, index) => `${JSON.stringify({ index, phase: "immediate" })}\n`, ).join(""); const rowBytes = encoder.encode(rows); const partialBytes = encoder.encode(`{"partial":"${"x".repeat(STREAM_MAX_BYTES)}"`); const result = yield* collectStreamingBody( Stream.make(rowBytes, partialBytes), "application/stream+json", ); expect(result.headers["x-executor-stream"]).toBe("truncated"); expect(result.headers["x-executor-stream-bytes"]).toBe(String(STREAM_MAX_BYTES)); expect(Array.isArray(result.data)).toBe(true); const data = result.data as unknown[]; expect(data.slice(0, 6)).toEqual( Array.from({ length: 6 }, (_, index) => ({ index, phase: "immediate" })), ); }), ); it.effect("collects every chunk from a delayed ReadableStream", () => Effect.gen(function* () { const chunks = [ encoder.encode(`${JSON.stringify({ id: 1 })}\n`), encoder.encode(`${JSON.stringify({ id: 2 })}\n`), encoder.encode(`${JSON.stringify({ id: 3 })}\n`), ]; const expectedBytes = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); const result = yield* collectStreamingBody( delayedReadableStream(chunks, 75, { close: true }), "application/x-ndjson", { maxBytes: STREAM_MAX_BYTES, maxMs: 1_000 }, ); console.log( JSON.stringify({ case: "delayed-readable-complete", bytes: result.headers["x-executor-stream-bytes"], rows: Array.isArray(result.data) ? result.data.length : 0, }), ); expect(result.headers["x-executor-stream"]).toBe("complete"); expect(result.headers["x-executor-stream-bytes"]).toBe(String(expectedBytes)); expect(result.data).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); }), ); it("timeout truncation preserves all chunks received before interruption", async () => { await Effect.runPromise( Effect.gen(function* () { const chunks = [ encoder.encode(`${JSON.stringify({ id: 1 })}\n`), encoder.encode(`${JSON.stringify({ id: 2 })}\n`), ]; const expectedBytes = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); const startedAt = Date.now(); const result = yield* collectStreamingBody( delayedReadableStream(chunks, 100, { close: false }), "application/stream+json", { maxBytes: STREAM_MAX_BYTES, maxMs: 500 }, ); const elapsedMs = Date.now() - startedAt; console.log( JSON.stringify({ case: "delayed-readable-timeout", elapsedMs, bytes: result.headers["x-executor-stream-bytes"], rows: Array.isArray(result.data) ? result.data.length : 0, }), ); expect(elapsedMs).toBeGreaterThanOrEqual(450); expect(elapsedMs).toBeLessThan(1_500); expect(result.headers["x-executor-stream"]).toBe("truncated"); expect(result.headers["x-executor-stream-bytes"]).toBe(String(expectedBytes)); expect(result.data).toEqual([{ id: 1 }, { id: 2 }]); }), ); }); it.effect("drops a trailing partial JSON line when the byte cap truncates", () => Effect.gen(function* () { let body = ""; for (let index = 0; Buffer.byteLength(body) < STREAM_MAX_BYTES - 2_000; index++) { body += `${JSON.stringify({ index, value: "x".repeat(512) })}\n`; } const encoded = encoder.encode(`${body}{"partial":"${"y".repeat(STREAM_MAX_BYTES)}"`); const result = yield* collectStreamingBody(Stream.make(encoded), "application/x-ndjson"); expect(result.headers["x-executor-stream"]).toBe("truncated"); expect(Array.isArray(result.data)).toBe(true); const data = result.data as unknown[]; expect(data.length).toBeGreaterThan(0); expect(data.every((row: unknown) => typeof row === "object" && row !== null)).toBe(true); expect(data.some((row: unknown) => "partial" in (row as Record))).toBe( false, ); }), ); it.effect("parses a completed short NDJSON stream", () => Effect.gen(function* () { const server = yield* startRawServer((res) => { res.writeHead(200, { "content-type": "application/jsonl" }); res.end(`${JSON.stringify({ id: 1 })}\n${JSON.stringify({ id: 2 })}\n`); }); const { executor, address } = yield* buildExecutor(server.baseUrl); const result = unwrapInvocation(yield* executor.execute(address, {})); expect(result.data).toEqual([{ id: 1 }, { id: 2 }]); expect(result.headers?.["x-executor-stream"]).toBe("complete"); }), ); it.effect("leaves ordinary JSON responses on the buffered path", () => Effect.gen(function* () { const server = yield* startRawServer((res) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); }); const { executor, address } = yield* buildExecutor(server.baseUrl); const result = unwrapInvocation>( yield* executor.execute(address, {}), ); expect(result.data).toEqual({ ok: true }); expect(result.headers?.["x-executor-stream"]).toBeUndefined(); }), ); it.effect("leaves non-2xx JSON error responses on the buffered path", () => Effect.gen(function* () { const server = yield* startRawServer((res) => { res.writeHead(422, { "content-type": "application/json" }); res.end('{"error":{"field":"name","reason":"too_short"}}'); }); const { executor, address } = yield* buildExecutor(server.baseUrl); const exit = yield* executor.execute(address, {}).pipe(Effect.exit); const text = Exit.match(exit, { onFailure: (cause) => JSON.stringify(cause), onSuccess: (value) => JSON.stringify(value), }); expect(text).toMatch(/422|too_short|name|response|error/i); expect(text).not.toMatch(/x-executor-stream/); }), ); it.effect("advertises an NDJSON operation's output as the array invoke returns", () => Effect.gen(function* () { // The spec declares the schema of ONE line (Vercel's runtime-logs // convention); the runtime returns an array of parsed lines. The // advertised output schema must describe that array on BOTH serve // paths (the add-time compile and the stored-binding rebuild) or // agents write code against a shape that never comes back. const lineSchema = { type: "object", properties: { level: { type: "string" }, message: { type: "string" } }, }; const server = yield* startRawServer((res) => { res.writeHead(200, { "content-type": "application/stream+json" }); res.end( `${JSON.stringify({ level: "info", message: "a" })}\n` + `${JSON.stringify({ level: "error", message: "b" })}\n`, ); }); const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); yield* executor.openapi.addSpec({ spec: { kind: "blob", value: JSON.stringify({ openapi: "3.0.3", info: { title: "Runtime Logs", version: "1.0.0" }, servers: [{ url: server.baseUrl }], paths: { "/logs": { get: { operationId: "getLogs", tags: ["logs"], responses: { "200": { description: "Log lines", content: { "application/stream+json": { schema: lineSchema } }, }, }, }, }, }, }), }, slug: "ndjson_logs", baseUrl: server.baseUrl, }); yield* executor.connections.create({ owner: "org", name: ConnectionName.make("main"), integration: IntegrationSlug.make("ndjson_logs"), template: AuthTemplateSlug.make("apiKey"), value: "token", }); const address = ToolAddress.make("tools.ndjson_logs.org.main.logs.getLogs"); const schema = yield* executor.tools.schema(address); const outputSchema = schema?.outputSchema as Record; expect(outputSchema.type).toBe("array"); expect(outputSchema.items).toEqual(lineSchema); // Force a catalog rebuild (the stored-binding serve path) to re-derive // the same array wrap from the persisted content type. yield* executor.connections.refresh({ owner: "org", integration: IntegrationSlug.make("ndjson_logs"), name: ConnectionName.make("main"), }); const rebuilt = yield* executor.tools.schema(address); const rebuiltOutput = rebuilt?.outputSchema as Record; expect(rebuiltOutput.type).toBe("array"); expect(rebuiltOutput.items).toEqual(lineSchema); // And the runtime value is exactly that array. const result = unwrapInvocation(yield* executor.execute(address, {})); expect(result.data).toEqual([ { level: "info", message: "a" }, { level: "error", message: "b" }, ]); }), ); });