session-model-transport-live.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import { describe, expect, test } from "bun:test"
  2. import { NodeSocket } from "@effect/platform-node"
  3. import { AIError, LLM, Message } from "@opencode-ai/ai"
  4. import {
  5. LLMClient,
  6. RequestExecutor,
  7. WebSocketTransport,
  8. type ChannelObservation,
  9. type WebSocketChannelExchange,
  10. } from "@opencode-ai/ai/route"
  11. import { configure } from "@opencode-ai/ai/providers/openai"
  12. import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
  13. import { Session } from "@opencode-ai/schema/session"
  14. import { Effect, Fiber, Layer, Stream } from "effect"
  15. import { Headers } from "effect/unstable/http"
  16. import { Socket } from "effect/unstable/socket"
  17. import { makeWebSocketServer, type WebSocketServerFixture, type WebSocketServerOptions } from "./lib/websocket-server"
  18. const sessionID = Session.ID.make("ses_live_websocket")
  19. const exchange = (server: WebSocketServerFixture, id: string): WebSocketChannelExchange => ({
  20. id,
  21. connect: {
  22. url: server.url,
  23. headers: Headers.fromInput({ authorization: "Bearer local-secret", "x-handshake": "visible" }),
  24. },
  25. fallback: () => Stream.die("Unexpected HTTP fallback"),
  26. driver: {
  27. create: () => Effect.succeed({ message: id, mode: "full" }),
  28. observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> =>
  29. Effect.succeed({ type: "completed", frame }),
  30. },
  31. })
  32. const withServer = <A>(
  33. options: WebSocketServerOptions,
  34. effect: (server: WebSocketServerFixture) => Effect.Effect<A, unknown, SessionModelTransport.Service>,
  35. ) =>
  36. Effect.runPromise(
  37. Effect.gen(function* () {
  38. const constructor = yield* Socket.WebSocketConstructor
  39. const server = yield* makeWebSocketServer(options)
  40. return yield* effect(server).pipe(
  41. Effect.provide(
  42. SessionModelTransport.makeLayer({
  43. open: (input) =>
  44. WebSocketTransport.open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
  45. }),
  46. ),
  47. )
  48. }).pipe(Effect.scoped, Effect.provide(NodeSocket.layerWebSocketConstructorWS)),
  49. )
  50. const collect = (transport: SessionModelTransport.Interface, item: WebSocketChannelExchange) =>
  51. Effect.gen(function* () {
  52. const execution = yield* transport.bind(sessionID).execute(item)
  53. return Array.from(yield* Stream.runCollect(execution.frames.pipe(Stream.onEnd(execution.complete))))
  54. }).pipe(Effect.scoped)
  55. const waitFor = (predicate: () => boolean, remaining = 100): Effect.Effect<void> => {
  56. if (predicate()) return Effect.void
  57. if (remaining === 0) return Effect.die("Timed out waiting for local WebSocket server")
  58. return Effect.sleep("5 millis").pipe(Effect.andThen(Effect.suspend(() => waitFor(predicate, remaining - 1))))
  59. }
  60. describe("SessionModelTransport local WebSocket server", () => {
  61. test("continues a real Responses connection with only the appended input", async () => {
  62. const requests: Array<Record<string, unknown>> = []
  63. await withServer(
  64. {
  65. message: (socket, message) => {
  66. const request = JSON.parse(message.toString())
  67. requests.push(request)
  68. const index = requests.length
  69. const id = `msg_${index}`
  70. const text = index === 1 ? "Hello" : "Brief"
  71. socket.send(JSON.stringify({ type: "response.created", response: { id: `resp_${index}` } }))
  72. socket.send(JSON.stringify({ type: "response.output_item.added", item: { type: "message", id } }))
  73. socket.send(JSON.stringify({ type: "response.output_text.delta", item_id: id, delta: text }))
  74. socket.send(JSON.stringify({ type: "response.output_text.done", item_id: id, text }))
  75. socket.send(
  76. JSON.stringify({
  77. type: "response.output_item.done",
  78. item: {
  79. type: "message",
  80. id,
  81. status: "completed",
  82. role: "assistant",
  83. content: [{ type: "output_text", text }],
  84. },
  85. }),
  86. )
  87. socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${index}` } }))
  88. },
  89. },
  90. (server) =>
  91. Effect.gen(function* () {
  92. const transport = yield* SessionModelTransport.Service
  93. const executor = transport.bind(sessionID)
  94. const model = configure({
  95. baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
  96. apiKey: "local",
  97. }).responses("gpt-5.2")
  98. const client = LLMClient.Service
  99. const layer = LLMClient.layer.pipe(
  100. Layer.provide(
  101. Layer.succeed(
  102. RequestExecutor.Service,
  103. RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
  104. ),
  105. ),
  106. )
  107. const first = yield* client
  108. .use((llm) => llm.generate(LLM.request({ model, prompt: "First" }), { webSocket: executor }))
  109. .pipe(Effect.provide(layer))
  110. const second = yield* client
  111. .use((llm) =>
  112. llm.generate(
  113. LLM.request({
  114. model,
  115. messages: [Message.user("First"), Message.assistant("Hello"), Message.user("Be brief")],
  116. }),
  117. { webSocket: executor },
  118. ),
  119. )
  120. .pipe(Effect.provide(layer))
  121. expect(first.text).toBe("Hello")
  122. expect(second.text).toBe("Brief")
  123. expect(server.state.opens).toBe(1)
  124. expect(requests[1]).toMatchObject({
  125. previous_response_id: "resp_1",
  126. input: [{ role: "user", content: [{ type: "input_text", text: "Be brief" }] }],
  127. })
  128. }),
  129. )
  130. })
  131. test("clears a rejected continuation and keeps one provider request per attempt", async () => {
  132. const requests: Array<Record<string, unknown>> = []
  133. await withServer(
  134. {
  135. message: (socket, message) => {
  136. requests.push(JSON.parse(message.toString()))
  137. const index = requests.length
  138. if (index === 2) {
  139. socket.send(
  140. JSON.stringify({
  141. type: "error",
  142. error: { code: "previous_response_not_found", message: "Missing response" },
  143. }),
  144. )
  145. return
  146. }
  147. const id = `msg_${index}`
  148. const text = index === 1 ? "Hello" : "Recovered"
  149. socket.send(JSON.stringify({ type: "response.created", response: { id: `resp_${index}` } }))
  150. socket.send(JSON.stringify({ type: "response.output_item.added", item: { type: "message", id } }))
  151. socket.send(JSON.stringify({ type: "response.output_text.delta", item_id: id, delta: text }))
  152. socket.send(JSON.stringify({ type: "response.output_text.done", item_id: id, text }))
  153. socket.send(
  154. JSON.stringify({
  155. type: "response.output_item.done",
  156. item: {
  157. type: "message",
  158. id,
  159. role: "assistant",
  160. content: [{ type: "output_text", text }],
  161. },
  162. }),
  163. )
  164. socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${index}` } }))
  165. },
  166. },
  167. (server) =>
  168. Effect.gen(function* () {
  169. const transport = yield* SessionModelTransport.Service
  170. const executor = transport.bind(sessionID)
  171. const model = configure({
  172. baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
  173. apiKey: "local",
  174. }).responses("gpt-5.2")
  175. const layer = LLMClient.layer.pipe(
  176. Layer.provide(
  177. Layer.succeed(
  178. RequestExecutor.Service,
  179. RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
  180. ),
  181. ),
  182. )
  183. const request = LLM.request({
  184. model,
  185. messages: [Message.user("First"), Message.assistant("Hello"), Message.user("Continue")],
  186. })
  187. yield* LLMClient.Service.use((llm) =>
  188. llm.generate(LLM.request({ model, prompt: "First" }), { webSocket: executor }),
  189. ).pipe(Effect.provide(layer))
  190. const rejected = yield* LLMClient.Service.use((llm) => llm.generate(request, { webSocket: executor })).pipe(
  191. Effect.provide(layer),
  192. Effect.flip,
  193. )
  194. const recovered = yield* LLMClient.Service.use((llm) => llm.generate(request, { webSocket: executor })).pipe(
  195. Effect.provide(layer),
  196. )
  197. expect(rejected.reason).toMatchObject({
  198. _tag: "Transport",
  199. delivery: "rejected",
  200. recovery: "retry-full",
  201. })
  202. expect(recovered.text).toBe("Recovered")
  203. expect(requests).toHaveLength(3)
  204. expect(requests[1]).toHaveProperty("previous_response_id", "resp_1")
  205. expect(requests[2]).not.toHaveProperty("previous_response_id")
  206. expect(server.state.opens).toBe(1)
  207. }),
  208. )
  209. })
  210. // The browser-compatible client surface cannot originate ping frames, so the server sends one and verifies pong.
  211. test("reuses one real connection with handshake headers and ping/pong", async () => {
  212. await withServer(
  213. {
  214. open: (socket) => socket.ping("health"),
  215. message: (socket, message) => socket.send(`completed:${message.toString()}`),
  216. },
  217. (server) =>
  218. Effect.gen(function* () {
  219. const transport = yield* SessionModelTransport.Service
  220. expect(yield* collect(transport, exchange(server, "first"))).toEqual(["completed:first"])
  221. expect(yield* collect(transport, exchange(server, "second"))).toEqual(["completed:second"])
  222. yield* waitFor(() => server.state.pongs === 1)
  223. expect(server.state.opens).toBe(1)
  224. expect(server.state.messages).toEqual(["first", "second"])
  225. expect(server.state.headers[0]).toMatchObject({
  226. authorization: "Bearer local-secret",
  227. "x-handshake": "visible",
  228. })
  229. }),
  230. )
  231. })
  232. test("closes a real active connection on cancellation", async () => {
  233. await withServer({ message: () => {} }, (server) =>
  234. Effect.gen(function* () {
  235. const transport = yield* SessionModelTransport.Service
  236. const running = yield* collect(transport, exchange(server, "blocked")).pipe(
  237. Effect.forkChild({ startImmediately: true }),
  238. )
  239. yield* waitFor(() => server.state.messages.length === 1)
  240. yield* Fiber.interrupt(running)
  241. yield* waitFor(() => server.state.closes === 1)
  242. expect(server.state.messages).toEqual(["blocked"])
  243. }),
  244. )
  245. })
  246. test("poisons a real connection after an unsupported binary frame", async () => {
  247. await withServer({ message: (socket) => socket.sendBinary(new Uint8Array([1, 2, 3])) }, (server) =>
  248. Effect.gen(function* () {
  249. const transport = yield* SessionModelTransport.Service
  250. const result = yield* Effect.result(collect(transport, exchange(server, "binary")))
  251. yield* waitFor(() => server.state.closes === 1)
  252. expect(result).toMatchObject({
  253. _tag: "Failure",
  254. failure: { reason: { _tag: "Transport", code: "message", delivery: "accepted" } },
  255. })
  256. }),
  257. )
  258. })
  259. test("closes a real connection after an oversized frame", async () => {
  260. await withServer({ message: (socket) => socket.send("x".repeat(16 * 1024 * 1024 + 1)) }, (server) =>
  261. Effect.gen(function* () {
  262. const transport = yield* SessionModelTransport.Service
  263. const result = yield* Effect.result(collect(transport, exchange(server, "oversized")))
  264. yield* waitFor(() => server.state.closes === 1)
  265. expect(result).toMatchObject({
  266. _tag: "Failure",
  267. failure: { reason: { _tag: "Transport", code: "message-too-large", delivery: "ambiguous" } },
  268. })
  269. }),
  270. )
  271. })
  272. // Effect's browser-compatible constructor does not expose upgrade response bodies or headers.
  273. // The real 426 fixture therefore pins the observable contract: a not-sent connect failure and one HTTP fallback.
  274. test("falls back once after a real rejected upgrade", async () => {
  275. let fallbacks = 0
  276. await withServer({ upgrade: () => false }, (server) =>
  277. Effect.gen(function* () {
  278. const transport = yield* SessionModelTransport.Service
  279. const item = exchange(server, "fallback")
  280. const result = yield* collect(transport, {
  281. ...item,
  282. fallback: () => {
  283. fallbacks++
  284. return Stream.make("http")
  285. },
  286. })
  287. expect(result).toEqual(["http"])
  288. expect(fallbacks).toBe(1)
  289. expect(server.state.opens).toBe(0)
  290. }),
  291. )
  292. })
  293. })