| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318 |
- import { describe, expect, test } from "bun:test"
- import { NodeSocket } from "@effect/platform-node"
- import { AIError, LLM, Message } from "@opencode-ai/ai"
- import {
- LLMClient,
- RequestExecutor,
- WebSocketTransport,
- type ChannelObservation,
- type WebSocketChannelExchange,
- } from "@opencode-ai/ai/route"
- import { configure } from "@opencode-ai/ai/providers/openai"
- import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
- import { Session } from "@opencode-ai/schema/session"
- import { Effect, Fiber, Layer, Stream } from "effect"
- import { Headers } from "effect/unstable/http"
- import { Socket } from "effect/unstable/socket"
- import { makeWebSocketServer, type WebSocketServerFixture, type WebSocketServerOptions } from "./lib/websocket-server"
- const sessionID = Session.ID.make("ses_live_websocket")
- const exchange = (server: WebSocketServerFixture, id: string): WebSocketChannelExchange => ({
- id,
- connect: {
- url: server.url,
- headers: Headers.fromInput({ authorization: "Bearer local-secret", "x-handshake": "visible" }),
- },
- fallback: () => Stream.die("Unexpected HTTP fallback"),
- driver: {
- create: () => Effect.succeed({ message: id, mode: "full" }),
- observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> =>
- Effect.succeed({ type: "completed", frame }),
- },
- })
- const withServer = <A>(
- options: WebSocketServerOptions,
- effect: (server: WebSocketServerFixture) => Effect.Effect<A, unknown, SessionModelTransport.Service>,
- ) =>
- Effect.runPromise(
- Effect.gen(function* () {
- const constructor = yield* Socket.WebSocketConstructor
- const server = yield* makeWebSocketServer(options)
- return yield* effect(server).pipe(
- Effect.provide(
- SessionModelTransport.makeLayer({
- open: (input) =>
- WebSocketTransport.open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
- }),
- ),
- )
- }).pipe(Effect.scoped, Effect.provide(NodeSocket.layerWebSocketConstructorWS)),
- )
- const collect = (transport: SessionModelTransport.Interface, item: WebSocketChannelExchange) =>
- Effect.gen(function* () {
- const execution = yield* transport.bind(sessionID).execute(item)
- return Array.from(yield* Stream.runCollect(execution.frames.pipe(Stream.onEnd(execution.complete))))
- }).pipe(Effect.scoped)
- const waitFor = (predicate: () => boolean, remaining = 100): Effect.Effect<void> => {
- if (predicate()) return Effect.void
- if (remaining === 0) return Effect.die("Timed out waiting for local WebSocket server")
- return Effect.sleep("5 millis").pipe(Effect.andThen(Effect.suspend(() => waitFor(predicate, remaining - 1))))
- }
- describe("SessionModelTransport local WebSocket server", () => {
- test("continues a real Responses connection with only the appended input", async () => {
- const requests: Array<Record<string, unknown>> = []
- await withServer(
- {
- message: (socket, message) => {
- const request = JSON.parse(message.toString())
- requests.push(request)
- const index = requests.length
- const id = `msg_${index}`
- const text = index === 1 ? "Hello" : "Brief"
- socket.send(JSON.stringify({ type: "response.created", response: { id: `resp_${index}` } }))
- socket.send(JSON.stringify({ type: "response.output_item.added", item: { type: "message", id } }))
- socket.send(JSON.stringify({ type: "response.output_text.delta", item_id: id, delta: text }))
- socket.send(JSON.stringify({ type: "response.output_text.done", item_id: id, text }))
- socket.send(
- JSON.stringify({
- type: "response.output_item.done",
- item: {
- type: "message",
- id,
- status: "completed",
- role: "assistant",
- content: [{ type: "output_text", text }],
- },
- }),
- )
- socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${index}` } }))
- },
- },
- (server) =>
- Effect.gen(function* () {
- const transport = yield* SessionModelTransport.Service
- const executor = transport.bind(sessionID)
- const model = configure({
- baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
- apiKey: "local",
- }).responses("gpt-5.2")
- const client = LLMClient.Service
- const layer = LLMClient.layer.pipe(
- Layer.provide(
- Layer.succeed(
- RequestExecutor.Service,
- RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
- ),
- ),
- )
- const first = yield* client
- .use((llm) => llm.generate(LLM.request({ model, prompt: "First" }), { webSocket: executor }))
- .pipe(Effect.provide(layer))
- const second = yield* client
- .use((llm) =>
- llm.generate(
- LLM.request({
- model,
- messages: [Message.user("First"), Message.assistant("Hello"), Message.user("Be brief")],
- }),
- { webSocket: executor },
- ),
- )
- .pipe(Effect.provide(layer))
- expect(first.text).toBe("Hello")
- expect(second.text).toBe("Brief")
- expect(server.state.opens).toBe(1)
- expect(requests[1]).toMatchObject({
- previous_response_id: "resp_1",
- input: [{ role: "user", content: [{ type: "input_text", text: "Be brief" }] }],
- })
- }),
- )
- })
- test("clears a rejected continuation and keeps one provider request per attempt", async () => {
- const requests: Array<Record<string, unknown>> = []
- await withServer(
- {
- message: (socket, message) => {
- requests.push(JSON.parse(message.toString()))
- const index = requests.length
- if (index === 2) {
- socket.send(
- JSON.stringify({
- type: "error",
- error: { code: "previous_response_not_found", message: "Missing response" },
- }),
- )
- return
- }
- const id = `msg_${index}`
- const text = index === 1 ? "Hello" : "Recovered"
- socket.send(JSON.stringify({ type: "response.created", response: { id: `resp_${index}` } }))
- socket.send(JSON.stringify({ type: "response.output_item.added", item: { type: "message", id } }))
- socket.send(JSON.stringify({ type: "response.output_text.delta", item_id: id, delta: text }))
- socket.send(JSON.stringify({ type: "response.output_text.done", item_id: id, text }))
- socket.send(
- JSON.stringify({
- type: "response.output_item.done",
- item: {
- type: "message",
- id,
- role: "assistant",
- content: [{ type: "output_text", text }],
- },
- }),
- )
- socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${index}` } }))
- },
- },
- (server) =>
- Effect.gen(function* () {
- const transport = yield* SessionModelTransport.Service
- const executor = transport.bind(sessionID)
- const model = configure({
- baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
- apiKey: "local",
- }).responses("gpt-5.2")
- const layer = LLMClient.layer.pipe(
- Layer.provide(
- Layer.succeed(
- RequestExecutor.Service,
- RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
- ),
- ),
- )
- const request = LLM.request({
- model,
- messages: [Message.user("First"), Message.assistant("Hello"), Message.user("Continue")],
- })
- yield* LLMClient.Service.use((llm) =>
- llm.generate(LLM.request({ model, prompt: "First" }), { webSocket: executor }),
- ).pipe(Effect.provide(layer))
- const rejected = yield* LLMClient.Service.use((llm) => llm.generate(request, { webSocket: executor })).pipe(
- Effect.provide(layer),
- Effect.flip,
- )
- const recovered = yield* LLMClient.Service.use((llm) => llm.generate(request, { webSocket: executor })).pipe(
- Effect.provide(layer),
- )
- expect(rejected.reason).toMatchObject({
- _tag: "Transport",
- delivery: "rejected",
- recovery: "retry-full",
- })
- expect(recovered.text).toBe("Recovered")
- expect(requests).toHaveLength(3)
- expect(requests[1]).toHaveProperty("previous_response_id", "resp_1")
- expect(requests[2]).not.toHaveProperty("previous_response_id")
- expect(server.state.opens).toBe(1)
- }),
- )
- })
- // The browser-compatible client surface cannot originate ping frames, so the server sends one and verifies pong.
- test("reuses one real connection with handshake headers and ping/pong", async () => {
- await withServer(
- {
- open: (socket) => socket.ping("health"),
- message: (socket, message) => socket.send(`completed:${message.toString()}`),
- },
- (server) =>
- Effect.gen(function* () {
- const transport = yield* SessionModelTransport.Service
- expect(yield* collect(transport, exchange(server, "first"))).toEqual(["completed:first"])
- expect(yield* collect(transport, exchange(server, "second"))).toEqual(["completed:second"])
- yield* waitFor(() => server.state.pongs === 1)
- expect(server.state.opens).toBe(1)
- expect(server.state.messages).toEqual(["first", "second"])
- expect(server.state.headers[0]).toMatchObject({
- authorization: "Bearer local-secret",
- "x-handshake": "visible",
- })
- }),
- )
- })
- test("closes a real active connection on cancellation", async () => {
- await withServer({ message: () => {} }, (server) =>
- Effect.gen(function* () {
- const transport = yield* SessionModelTransport.Service
- const running = yield* collect(transport, exchange(server, "blocked")).pipe(
- Effect.forkChild({ startImmediately: true }),
- )
- yield* waitFor(() => server.state.messages.length === 1)
- yield* Fiber.interrupt(running)
- yield* waitFor(() => server.state.closes === 1)
- expect(server.state.messages).toEqual(["blocked"])
- }),
- )
- })
- test("poisons a real connection after an unsupported binary frame", async () => {
- await withServer({ message: (socket) => socket.sendBinary(new Uint8Array([1, 2, 3])) }, (server) =>
- Effect.gen(function* () {
- const transport = yield* SessionModelTransport.Service
- const result = yield* Effect.result(collect(transport, exchange(server, "binary")))
- yield* waitFor(() => server.state.closes === 1)
- expect(result).toMatchObject({
- _tag: "Failure",
- failure: { reason: { _tag: "Transport", code: "message", delivery: "accepted" } },
- })
- }),
- )
- })
- test("closes a real connection after an oversized frame", async () => {
- await withServer({ message: (socket) => socket.send("x".repeat(16 * 1024 * 1024 + 1)) }, (server) =>
- Effect.gen(function* () {
- const transport = yield* SessionModelTransport.Service
- const result = yield* Effect.result(collect(transport, exchange(server, "oversized")))
- yield* waitFor(() => server.state.closes === 1)
- expect(result).toMatchObject({
- _tag: "Failure",
- failure: { reason: { _tag: "Transport", code: "message-too-large", delivery: "ambiguous" } },
- })
- }),
- )
- })
- // Effect's browser-compatible constructor does not expose upgrade response bodies or headers.
- // The real 426 fixture therefore pins the observable contract: a not-sent connect failure and one HTTP fallback.
- test("falls back once after a real rejected upgrade", async () => {
- let fallbacks = 0
- await withServer({ upgrade: () => false }, (server) =>
- Effect.gen(function* () {
- const transport = yield* SessionModelTransport.Service
- const item = exchange(server, "fallback")
- const result = yield* collect(transport, {
- ...item,
- fallback: () => {
- fallbacks++
- return Stream.make("http")
- },
- })
- expect(result).toEqual(["http"])
- expect(fallbacks).toBe(1)
- expect(server.state.opens).toBe(0)
- }),
- )
- })
- })
|