refactor(simulation): scope Drive lifecycle with Effect (#36908)
This commit is contained in:
parent
a149a61a89
commit
947566f611
30 changed files with 1313 additions and 600 deletions
26
packages/simulation/test/fixture/websocket.ts
Normal file
26
packages/simulation/test/fixture/websocket.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Effect } from "effect"
|
||||
|
||||
export function availableEndpoint() {
|
||||
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||
const endpoint = `ws://127.0.0.1:${server.port}`
|
||||
server.stop(true)
|
||||
return endpoint
|
||||
}
|
||||
|
||||
export function connect(endpoint: string) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.callback<WebSocket, Error>((resume) => {
|
||||
const socket = new WebSocket(endpoint)
|
||||
const open = () => resume(Effect.succeed(socket))
|
||||
const error = () => resume(Effect.fail(new Error(`Failed to connect to ${endpoint}`)))
|
||||
socket.addEventListener("open", open, { once: true })
|
||||
socket.addEventListener("error", error, { once: true })
|
||||
return Effect.sync(() => {
|
||||
socket.removeEventListener("open", open)
|
||||
socket.removeEventListener("error", error)
|
||||
socket.close()
|
||||
})
|
||||
}),
|
||||
(socket) => Effect.sync(() => socket.close()),
|
||||
)
|
||||
}
|
||||
40
packages/simulation/test/frontend-server.test.ts
Normal file
40
packages/simulation/test/frontend-server.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem, Queue } from "effect"
|
||||
import { SimulationActions } from "../src/frontend/actions"
|
||||
import { SimulationRenderer } from "../src/frontend/renderer"
|
||||
import { SimulationServer } from "../src/frontend/server"
|
||||
import { availableEndpoint, connect } from "./fixture/websocket"
|
||||
|
||||
test("scopes the frontend control server and reports malformed JSON", async () => {
|
||||
const endpoint = availableEndpoint()
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const renderer = yield* SimulationRenderer.create({})
|
||||
yield* SimulationServer.start(SimulationActions.createHarness(renderer), endpoint)
|
||||
const socket = yield* connect(endpoint)
|
||||
const messages = yield* Queue.unbounded<unknown>()
|
||||
socket.addEventListener("message", (event) => {
|
||||
Queue.offerUnsafe(messages, JSON.parse(String(event.data)))
|
||||
})
|
||||
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ui.state" }))
|
||||
expect(yield* Queue.take(messages)).toMatchObject({
|
||||
id: 1,
|
||||
result: { focused: { editor: false }, elements: [] },
|
||||
})
|
||||
|
||||
socket.send("{")
|
||||
expect(yield* Queue.take(messages)).toMatchObject({
|
||||
id: null,
|
||||
error: { code: -32000 },
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
const url = new URL(endpoint)
|
||||
const rebound = Bun.serve({ hostname: url.hostname, port: Number(url.port), fetch: () => new Response() })
|
||||
await rebound.stop(true)
|
||||
})
|
||||
74
packages/simulation/test/manifest.test.ts
Normal file
74
packages/simulation/test/manifest.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect, FileSystem, Layer } from "effect"
|
||||
import { DriveManifest } from "../src/manifest"
|
||||
|
||||
test("loads and validates a Drive manifest through Effect services", async () => {
|
||||
const manifest = await Effect.runPromise(
|
||||
DriveManifest.resolve().pipe(
|
||||
Effect.provide(
|
||||
Layer.merge(
|
||||
FileSystem.layerNoop({
|
||||
readFileString: () =>
|
||||
Effect.succeed(
|
||||
JSON.stringify({
|
||||
endpoints: {
|
||||
ui: "ws://127.0.0.1:41000",
|
||||
backend: "ws://127.0.0.1:41050",
|
||||
},
|
||||
viewport: { cols: 120, rows: 50 },
|
||||
recording: { timeline: "/tmp/drive/timeline.jsonl" },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_DRIVE: "test-instance",
|
||||
DRIVE_REGISTRY_DIR: "/tmp/drive",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(manifest).toEqual({
|
||||
endpoints: {
|
||||
ui: "ws://127.0.0.1:41000",
|
||||
backend: "ws://127.0.0.1:41050",
|
||||
},
|
||||
viewport: { cols: 120, rows: 50 },
|
||||
recording: { timeline: "/tmp/drive/timeline.jsonl" },
|
||||
})
|
||||
})
|
||||
|
||||
test("reports schema-invalid manifests as typed decode failures", async () => {
|
||||
const error = await Effect.runPromise(
|
||||
DriveManifest.resolve().pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
Layer.merge(
|
||||
FileSystem.layerNoop({
|
||||
readFileString: () =>
|
||||
Effect.succeed(
|
||||
JSON.stringify({
|
||||
endpoints: {
|
||||
ui: "https://example.com",
|
||||
backend: "ws://127.0.0.1:41050",
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_DRIVE: "test-instance",
|
||||
DRIVE_REGISTRY_DIR: "/tmp/drive",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(DriveManifest.ResolveError)
|
||||
expect(error.reason).toBe("decode")
|
||||
})
|
||||
31
packages/simulation/test/network.test.ts
Normal file
31
packages/simulation/test/network.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { Effect, Exit } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { SimulationNetwork } from "../src/backend/network"
|
||||
|
||||
test("keeps routes and request logs local to each network", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(1_234)
|
||||
const first = yield* SimulationNetwork.make([
|
||||
SimulationNetwork.json("GET", "https://example.test/value", { source: "first" }),
|
||||
])
|
||||
const second = yield* SimulationNetwork.make()
|
||||
const request = HttpClientRequest.get("https://example.test/value")
|
||||
|
||||
const response = yield* first.client.execute(request)
|
||||
expect(yield* response.text).toBe('{"source":"first"}')
|
||||
expect(Exit.isFailure(yield* second.client.execute(request).pipe(Effect.exit))).toBe(true)
|
||||
|
||||
expect(yield* first.log()).toEqual([
|
||||
{ time: 1_234, method: "GET", url: "https://example.test/value", matched: true },
|
||||
])
|
||||
expect(yield* second.log()).toEqual([
|
||||
{ time: 1_234, method: "GET", url: "https://example.test/value", matched: false },
|
||||
])
|
||||
}).pipe(Effect.provide(TestClock.layer())),
|
||||
),
|
||||
)
|
||||
})
|
||||
47
packages/simulation/test/openai.test.ts
Normal file
47
packages/simulation/test/openai.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { HttpClientError } from "effect/unstable/http/HttpClientError"
|
||||
import { SimulationOpenAI } from "../src/backend/openai"
|
||||
import { SimulatedProvider } from "../src/backend/simulated-provider"
|
||||
|
||||
test("encodes every simulated provider event as OpenAI SSE", async () => {
|
||||
const provider: SimulatedProvider.Interface = {
|
||||
stream: () =>
|
||||
Stream.make(
|
||||
{ type: "textDelta", text: "Hello " },
|
||||
{ type: "textDelta", text: "from Drive" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
),
|
||||
}
|
||||
const url = new URL(DEFAULT_BASE_URL + PATH)
|
||||
const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bodyJsonUnsafe({ model: "gpt-5" }))
|
||||
const matched = SimulationOpenAI.route(provider).match(request, url)
|
||||
if (!matched) throw new Error("The simulated OpenAI route did not match")
|
||||
|
||||
const body = await Effect.runPromise(matched.pipe(Effect.flatMap((response) => response.text)))
|
||||
|
||||
expect(body).toBe(
|
||||
[
|
||||
'data: {"choices":[{"delta":{"content":"Hello "}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"from Drive"}}]}',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
|
||||
"data: [DONE]",
|
||||
"",
|
||||
].join("\n\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects malformed intercepted OpenAI JSON as an HTTP client error", async () => {
|
||||
const provider: SimulatedProvider.Interface = { stream: () => Stream.empty }
|
||||
const url = new URL(DEFAULT_BASE_URL + PATH)
|
||||
const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bodyText("{"))
|
||||
const matched = SimulationOpenAI.route(provider).match(request, url)
|
||||
if (!matched) throw new Error("The simulated OpenAI route did not match")
|
||||
|
||||
const error = await Effect.runPromise(matched.pipe(Effect.flip))
|
||||
|
||||
expect(error).toBeInstanceOf(HttpClientError)
|
||||
expect(error.reason._tag).toBe("TransportError")
|
||||
})
|
||||
61
packages/simulation/test/png.test.ts
Normal file
61
packages/simulation/test/png.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas"
|
||||
import { RGBA, TextAttributes, type CapturedFrame } from "@opentui/core"
|
||||
import { SimulationPng } from "../src/frontend/png"
|
||||
|
||||
test("renders captured frames with bundled fonts", () => {
|
||||
const frame: CapturedFrame = {
|
||||
cols: 4,
|
||||
rows: 1,
|
||||
cursor: [0, 0],
|
||||
lines: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
text: "Test",
|
||||
width: 4,
|
||||
fg: RGBA.fromInts(255, 255, 255),
|
||||
bg: RGBA.fromInts(0, 0, 0),
|
||||
attributes: TextAttributes.BOLD | TextAttributes.ITALIC,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const image = SimulationPng.screenshotFrame(frame)
|
||||
expect(image.width).toBe(40)
|
||||
expect(image.height).toBe(20)
|
||||
expect(image.data.subarray(1, 4).toString()).toBe("PNG")
|
||||
})
|
||||
|
||||
test("fills adjacent block elements without glyph gaps", async () => {
|
||||
const image = SimulationPng.screenshotFrame({
|
||||
cols: 2,
|
||||
rows: 1,
|
||||
cursor: [0, 0],
|
||||
lines: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
text: "▀▀",
|
||||
width: 2,
|
||||
fg: RGBA.fromInts(255, 255, 255),
|
||||
bg: RGBA.fromInts(0, 0, 0),
|
||||
attributes: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const canvas = createCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")
|
||||
context.drawImage(await loadImage(image.data), 0, 0)
|
||||
|
||||
expect([...context.getImageData(0, 5, image.width, 1).data]).toEqual(
|
||||
Array.from({ length: image.width }, () => [255, 255, 255, 255]).flat(),
|
||||
)
|
||||
expect([...context.getImageData(0, 15, image.width, 1).data]).toEqual(
|
||||
Array.from({ length: image.width }, () => [0, 0, 0, 255]).flat(),
|
||||
)
|
||||
})
|
||||
|
|
@ -5,6 +5,7 @@ import { join } from "node:path"
|
|||
import { TextRenderable } from "@opentui/core"
|
||||
import { createHarness, matches } from "../src/frontend/actions"
|
||||
import { SimulationRenderer } from "../src/frontend/renderer"
|
||||
import { Effect } from "effect"
|
||||
import { Timeline, type Event } from "../src/recording"
|
||||
|
||||
test("streams ANSI chunks into a versioned JSONL timeline", async () => {
|
||||
|
|
@ -39,21 +40,25 @@ test("streams ANSI chunks into a versioned JSONL timeline", async () => {
|
|||
test("captures native renderer output and finishes on destroy", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "simulation-renderer-recording-"))
|
||||
const path = join(directory, "timeline.jsonl")
|
||||
const renderer = await SimulationRenderer.create({}, path)
|
||||
|
||||
try {
|
||||
await SimulationRenderer.setupFor(renderer)?.renderOnce()
|
||||
renderer.destroy()
|
||||
expect(await SimulationRenderer.finish(renderer)).toBe(path)
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const renderer = yield* SimulationRenderer.create({}, path)
|
||||
yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve())
|
||||
renderer.destroy()
|
||||
expect(yield* SimulationRenderer.finish(renderer)).toBe(path)
|
||||
|
||||
const events = (await Bun.file(path).text())
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Event)
|
||||
expect(events.some((event) => event.type === "output")).toBe(true)
|
||||
const events = (yield* Effect.promise(() => Bun.file(path).text()))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Event)
|
||||
expect(events.some((event) => event.type === "output")).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
await SimulationRenderer.finish(renderer)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
@ -61,16 +66,20 @@ test("captures native renderer output and finishes on destroy", async () => {
|
|||
test("matches live screen text while recording", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "simulation-recording-matches-"))
|
||||
const path = join(directory, "timeline.jsonl")
|
||||
const renderer = await SimulationRenderer.create({}, path)
|
||||
|
||||
try {
|
||||
renderer.root.add(new TextRenderable(renderer, { content: "recorded screen text" }))
|
||||
await SimulationRenderer.setupFor(renderer)?.renderOnce()
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const renderer = yield* SimulationRenderer.create({}, path)
|
||||
renderer.root.add(new TextRenderable(renderer, { content: "recorded screen text" }))
|
||||
yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve())
|
||||
|
||||
expect(matches(createHarness(renderer), "recorded screen text")).toBe(true)
|
||||
expect(matches(createHarness(renderer), "recorded screen text")).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
renderer.destroy()
|
||||
await SimulationRenderer.finish(renderer)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
230
packages/simulation/test/simulated-provider.test.ts
Normal file
230
packages/simulation/test/simulated-provider.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Queue, Stream } from "effect"
|
||||
import type { Scope } from "effect/Scope"
|
||||
import { SimulatedProvider } from "../src/backend/simulated-provider"
|
||||
import { availableEndpoint, connect } from "./fixture/websocket"
|
||||
|
||||
test("streams a Drive-controlled provider response and removes the finished invocation", async () => {
|
||||
await runProvider((provider, socket, messages) =>
|
||||
Effect.gen(function* () {
|
||||
socket.send("{")
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: null, error: { code: -32000 } })
|
||||
yield* attach(socket, messages)
|
||||
|
||||
const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
const opened = yield* takeInvocation(messages)
|
||||
expect(opened).toMatchObject({
|
||||
method: "llm.request",
|
||||
params: {
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
body: { model: "gpt-5" },
|
||||
},
|
||||
})
|
||||
const params = requireRecord(opened.params)
|
||||
if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id")
|
||||
expect(response.pollUnsafe()).toBeUndefined()
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "llm.chunk",
|
||||
params: { id: params.id, items: [{ type: "textDelta", text: "Hello from Drive" }] },
|
||||
}),
|
||||
)
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } })
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "llm.finish",
|
||||
params: { id: params.id, reason: "stop" },
|
||||
}),
|
||||
)
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 3, result: { ok: true } })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(response))).toEqual([
|
||||
{ type: "textDelta", text: "Hello from Drive" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
])
|
||||
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 4, method: "llm.pending" }))
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 4, result: { invocations: [] } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("replays an invocation to a controller that attaches after it opens", async () => {
|
||||
await runProvider((provider, socket, messages) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "llm.attach" }))
|
||||
const received = [requireRecord(yield* Queue.take(messages)), requireRecord(yield* Queue.take(messages))]
|
||||
expect(received).toContainEqual(expect.objectContaining({ id: 1, result: { attached: true } }))
|
||||
const opened = received.find((message) => message.method === "llm.request")
|
||||
if (!opened) throw new Error("The pending invocation was not replayed")
|
||||
const params = requireRecord(opened.params)
|
||||
if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id")
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.finish", params: { id: params.id, reason: "stop" } }),
|
||||
)
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } })
|
||||
expect(Array.from(yield* Fiber.join(response))).toEqual([{ type: "finish", reason: "stop" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("replaces the previous attached controller", async () => {
|
||||
const endpoint = availableEndpoint()
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* SimulatedProvider.Service
|
||||
const first = yield* connect(endpoint)
|
||||
const second = yield* connect(endpoint)
|
||||
const firstMessages = yield* messagesFrom(first)
|
||||
const secondMessages = yield* messagesFrom(second)
|
||||
|
||||
yield* attach(first, firstMessages)
|
||||
yield* attach(second, secondMessages)
|
||||
const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.forkScoped)
|
||||
const opened = yield* takeInvocation(secondMessages)
|
||||
expect(yield* Queue.size(firstMessages)).toBe(0)
|
||||
const params = requireRecord(opened.params)
|
||||
if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id")
|
||||
|
||||
second.send(
|
||||
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.finish", params: { id: params.id, reason: "stop" } }),
|
||||
)
|
||||
expect(yield* Queue.take(secondMessages)).toMatchObject({ id: 2, result: { ok: true } })
|
||||
expect(Array.from(yield* Fiber.join(response))).toEqual([{ type: "finish", reason: "stop" }])
|
||||
}).pipe(Effect.provide(SimulatedProvider.layerDrive({ endpoint })), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
test("removes an invocation when its response stream is interrupted", async () => {
|
||||
await runProvider((provider, socket, messages) =>
|
||||
Effect.gen(function* () {
|
||||
yield* attach(socket, messages)
|
||||
const response = yield* provider.stream(request).pipe(Stream.runDrain, Effect.forkScoped)
|
||||
yield* takeInvocation(messages)
|
||||
|
||||
yield* Fiber.interrupt(response)
|
||||
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.pending" }))
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { invocations: [] } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("releases a backpressured response when its consumer is interrupted", async () => {
|
||||
await runProvider((provider, socket, messages) =>
|
||||
Effect.gen(function* () {
|
||||
yield* attach(socket, messages)
|
||||
const started = yield* Deferred.make<void>()
|
||||
const response = yield* provider.stream(request).pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(started, void 0).pipe(Effect.andThen(Effect.never))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const opened = yield* takeInvocation(messages)
|
||||
const params = requireRecord(opened.params)
|
||||
if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id")
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "llm.chunk",
|
||||
params: {
|
||||
id: params.id,
|
||||
items: Array.from({ length: 300 }, (_, index) => ({ type: "textDelta", text: String(index) })),
|
||||
},
|
||||
}),
|
||||
)
|
||||
const result = yield* Queue.take(messages).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
expect(result.pollUnsafe()).toBeUndefined()
|
||||
|
||||
yield* Fiber.interrupt(response)
|
||||
expect(yield* Fiber.join(result)).toMatchObject({ id: 2 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("fails the provider stream when Drive disconnects the invocation", async () => {
|
||||
await runProvider((provider, socket, messages) =>
|
||||
Effect.gen(function* () {
|
||||
yield* attach(socket, messages)
|
||||
const response = yield* provider.stream(request).pipe(Stream.runCollect, Effect.flip, Effect.forkScoped)
|
||||
const opened = yield* takeInvocation(messages)
|
||||
const params = requireRecord(opened.params)
|
||||
if (typeof params.id !== "string") throw new Error("llm.request did not contain an invocation id")
|
||||
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "llm.disconnect", params: { id: params.id } }))
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } })
|
||||
expect(yield* Fiber.join(response)).toBeInstanceOf(SimulatedProvider.ProviderDisconnectedError)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const request: SimulatedProvider.ProviderRequest = {
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
body: { model: "gpt-5", messages: [{ role: "user", content: "Hello" }] },
|
||||
}
|
||||
|
||||
function runProvider<E>(
|
||||
body: (
|
||||
provider: SimulatedProvider.Interface,
|
||||
socket: WebSocket,
|
||||
messages: Queue.Queue<unknown>,
|
||||
) => Effect.Effect<void, E, Scope>,
|
||||
) {
|
||||
const endpoint = availableEndpoint()
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* SimulatedProvider.Service
|
||||
const socket = yield* connect(endpoint)
|
||||
const messages = yield* messagesFrom(socket)
|
||||
yield* body(provider, socket, messages)
|
||||
}).pipe(Effect.provide(SimulatedProvider.layerDrive({ endpoint })), Effect.scoped),
|
||||
)
|
||||
}
|
||||
|
||||
function messagesFrom(socket: WebSocket) {
|
||||
return Effect.gen(function* () {
|
||||
const messages = yield* Queue.unbounded<unknown>()
|
||||
socket.addEventListener("message", (event) => {
|
||||
Queue.offerUnsafe(messages, JSON.parse(String(event.data)))
|
||||
})
|
||||
return messages
|
||||
})
|
||||
}
|
||||
|
||||
function attach(socket: WebSocket, messages: Queue.Queue<unknown>) {
|
||||
return Effect.gen(function* () {
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "llm.attach" }))
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 1, result: { attached: true } })
|
||||
})
|
||||
}
|
||||
|
||||
function takeInvocation(messages: Queue.Queue<unknown>) {
|
||||
return Queue.take(messages).pipe(
|
||||
Effect.map((message) => {
|
||||
const opened = requireRecord(message)
|
||||
if (opened.method !== "llm.request") throw new Error("Expected an llm.request notification")
|
||||
return opened
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error("Expected an object")
|
||||
return value
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue