session-model-transport.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. import { describe, expect, test } from "bun:test"
  2. import { AIError, TransportReason } from "@opencode-ai/ai"
  3. import type {
  4. ChannelObservation,
  5. WebSocketChannelExchange,
  6. WebSocketConnection,
  7. WebSocketConnector,
  8. } from "@opencode-ai/ai/route"
  9. import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
  10. import { Session } from "@opencode-ai/schema/session"
  11. import { Deferred, Effect, Fiber, Metric, Queue, Stream } from "effect"
  12. import { TestClock } from "effect/testing"
  13. import { Headers } from "effect/unstable/http"
  14. const session = Session.ID.make("ses_transport")
  15. const otherSession = Session.ID.make("ses_transport_other")
  16. const queue = <A, E = never>() => Effect.runSync(Queue.unbounded<A, E>())
  17. const error = (message: string, delivery?: TransportReason["delivery"]) =>
  18. new AIError({
  19. module: "test",
  20. method: "websocket",
  21. reason: new TransportReason({ message, transport: "websocket", operation: "write", phase: "send", delivery }),
  22. })
  23. const exchange = (
  24. id: string,
  25. input: {
  26. readonly headers?: Record<string, string>
  27. readonly fallback?: () => Stream.Stream<string, AIError>
  28. readonly rotateAfterMs?: number
  29. } = {},
  30. ): WebSocketChannelExchange => ({
  31. id,
  32. connect: {
  33. url: "wss://provider.test/responses",
  34. headers: Headers.fromInput(input.headers),
  35. rotateAfterMs: input.rotateAfterMs,
  36. },
  37. fallback: input.fallback ?? (() => Stream.make(`fallback:${id}`)),
  38. driver: {
  39. create: () => Effect.succeed({ message: id, mode: "full" }),
  40. observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> =>
  41. Effect.succeed({ type: "completed", frame }),
  42. },
  43. })
  44. const run = <A, E>(connector: WebSocketConnector, effect: Effect.Effect<A, E, SessionModelTransport.Service>) =>
  45. Effect.runPromise(effect.pipe(Effect.provide(SessionModelTransport.makeLayer(connector)), Effect.scoped))
  46. const runWithTestClock = <A, E>(
  47. connector: WebSocketConnector,
  48. effect: Effect.Effect<A, E, SessionModelTransport.Service>,
  49. ) =>
  50. Effect.runPromise(
  51. effect.pipe(
  52. Effect.provide(SessionModelTransport.makeLayer(connector)),
  53. Effect.scoped,
  54. Effect.provide(TestClock.layer()),
  55. ),
  56. )
  57. const collect = (executor: ReturnType<SessionModelTransport.Interface["bind"]>, item: WebSocketChannelExchange) =>
  58. Effect.gen(function* () {
  59. const execution = yield* executor.execute(item)
  60. return Array.from(yield* Stream.runCollect(execution.frames))
  61. }).pipe(Effect.scoped)
  62. const collectComplete = (
  63. executor: ReturnType<SessionModelTransport.Interface["bind"]>,
  64. item: WebSocketChannelExchange,
  65. ) =>
  66. Effect.gen(function* () {
  67. const execution = yield* executor.execute(item)
  68. return Array.from(yield* Stream.runCollect(execution.frames.pipe(Stream.onEnd(execution.complete))))
  69. }).pipe(Effect.scoped)
  70. const automatic = () => {
  71. const connections: Array<{
  72. readonly messages: Queue.Queue<string | Uint8Array, AIError>
  73. closed: number
  74. sent: string[]
  75. }> = []
  76. const connector: WebSocketConnector = {
  77. open: () =>
  78. Effect.gen(function* () {
  79. const messages = yield* Queue.unbounded<string | Uint8Array, AIError>()
  80. const record = { messages, closed: 0, sent: [] as string[] }
  81. connections.push(record)
  82. const connection: WebSocketConnection = {
  83. sendText: (message) =>
  84. Effect.sync(() => {
  85. record.sent.push(message)
  86. Queue.offerUnsafe(messages, `completed:${message}`)
  87. }),
  88. messages: Stream.fromQueue(messages),
  89. close: Effect.sync(() => {
  90. record.closed++
  91. }).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
  92. }
  93. return connection
  94. }),
  95. }
  96. return { connector, connections }
  97. }
  98. describe("SessionModelTransport", () => {
  99. test("commits checkpoints only after successful outer completion", async () => {
  100. const messages = queue<string | Uint8Array, AIError>()
  101. const checkpoints: Array<unknown> = []
  102. const candidate = { protocol: "test", value: { response: "one" } }
  103. const connector: WebSocketConnector = {
  104. open: () =>
  105. Effect.succeed({
  106. sendText: (message) =>
  107. Effect.sync(() => Queue.offerUnsafe(messages, `completed:${message}`)).pipe(Effect.asVoid),
  108. messages: Stream.fromQueue(messages),
  109. close: Queue.shutdown(messages).pipe(Effect.asVoid),
  110. }),
  111. }
  112. const item = (id: string): WebSocketChannelExchange => ({
  113. ...exchange(id),
  114. driver: {
  115. create: (checkpoint) =>
  116. Effect.sync(() => {
  117. checkpoints.push(checkpoint)
  118. return { message: id, mode: checkpoint ? "incremental" : "full" }
  119. }),
  120. observe: (_create, frame) => Effect.succeed({ type: "completed", frame, checkpoint: candidate }),
  121. },
  122. })
  123. await run(
  124. connector,
  125. Effect.gen(function* () {
  126. const transport = yield* SessionModelTransport.Service
  127. const executor = transport.bind(session)
  128. yield* collectComplete(executor, item("first"))
  129. yield* collect(executor, item("second"))
  130. yield* collect(executor, item("third"))
  131. expect(checkpoints).toEqual([undefined, candidate, undefined])
  132. }),
  133. )
  134. })
  135. test("does not carry a checkpoint across physical connection rotation", async () => {
  136. const fixture = automatic()
  137. const checkpoints: Array<unknown> = []
  138. const candidate = { protocol: "test", value: { response: "one" } }
  139. const item = (id: string, authorization: string): WebSocketChannelExchange => ({
  140. ...exchange(id, { headers: { authorization } }),
  141. driver: {
  142. create: (checkpoint) =>
  143. Effect.sync(() => {
  144. checkpoints.push(checkpoint)
  145. return { message: id, mode: checkpoint ? "incremental" : "full" }
  146. }),
  147. observe: (_create, frame) => Effect.succeed({ type: "completed", frame, checkpoint: candidate }),
  148. },
  149. })
  150. await run(
  151. fixture.connector,
  152. Effect.gen(function* () {
  153. const transport = yield* SessionModelTransport.Service
  154. const executor = transport.bind(session)
  155. yield* collectComplete(executor, item("first", "one"))
  156. yield* collect(executor, item("second", "two"))
  157. expect(checkpoints).toEqual([undefined, undefined])
  158. expect(fixture.connections).toHaveLength(2)
  159. }),
  160. )
  161. })
  162. test("clears a rejected checkpoint before the runner retries full", async () => {
  163. const fixture = automatic()
  164. const checkpoints: Array<unknown> = []
  165. const candidate = { protocol: "test", value: { response: "one" } }
  166. const item = (id: string): WebSocketChannelExchange => ({
  167. ...exchange(id),
  168. driver: {
  169. create: (checkpoint) =>
  170. Effect.sync(() => {
  171. checkpoints.push(checkpoint)
  172. return { message: id, mode: checkpoint ? "incremental" : "full" }
  173. }),
  174. observe: (_create, frame) =>
  175. id === "rejected"
  176. ? Effect.succeed({
  177. type: "rejected",
  178. recovery: "retry-full",
  179. error: new AIError({
  180. module: "test",
  181. method: "stream",
  182. reason: new TransportReason({
  183. message: "missing response",
  184. transport: "websocket",
  185. operation: "read",
  186. phase: "receive",
  187. delivery: "rejected",
  188. recovery: "retry-full",
  189. }),
  190. }),
  191. })
  192. : Effect.succeed({ type: "completed", frame, checkpoint: candidate }),
  193. },
  194. })
  195. await run(
  196. fixture.connector,
  197. Effect.gen(function* () {
  198. const transport = yield* SessionModelTransport.Service
  199. const executor = transport.bind(session)
  200. yield* collectComplete(executor, item("first"))
  201. yield* Effect.result(collect(executor, item("rejected")))
  202. yield* collect(executor, item("retry"))
  203. expect(checkpoints).toEqual([undefined, candidate, undefined])
  204. expect(fixture.connections).toHaveLength(1)
  205. }),
  206. )
  207. })
  208. test("rotates after the provider rejects the connection generation", async () => {
  209. const fixture = automatic()
  210. const rejected: WebSocketChannelExchange = {
  211. ...exchange("rejected"),
  212. driver: {
  213. create: () => Effect.succeed({ message: "rejected", mode: "incremental" }),
  214. observe: () =>
  215. Effect.succeed({
  216. type: "rejected",
  217. recovery: "rotate-and-retry-full",
  218. error: new AIError({
  219. module: "test",
  220. method: "stream",
  221. reason: new TransportReason({
  222. message: "connection limit",
  223. transport: "websocket",
  224. operation: "read",
  225. phase: "receive",
  226. delivery: "rejected",
  227. recovery: "rotate-and-retry-full",
  228. }),
  229. }),
  230. }),
  231. },
  232. }
  233. await run(
  234. fixture.connector,
  235. Effect.gen(function* () {
  236. const transport = yield* SessionModelTransport.Service
  237. const executor = transport.bind(session)
  238. yield* Effect.result(collect(executor, rejected))
  239. yield* collect(executor, exchange("retry"))
  240. expect(fixture.connections).toHaveLength(2)
  241. expect(fixture.connections[0]?.closed).toBe(1)
  242. }),
  243. )
  244. })
  245. test("reuses one physical connection for sequential Session calls", async () => {
  246. const fixture = automatic()
  247. await run(
  248. fixture.connector,
  249. Effect.gen(function* () {
  250. const transport = yield* SessionModelTransport.Service
  251. expect(yield* collect(transport.bind(session), exchange("first"))).toEqual(["completed:first"])
  252. expect(yield* collect(transport.bind(session), exchange("second"))).toEqual(["completed:second"])
  253. expect(fixture.connections).toHaveLength(1)
  254. expect(fixture.connections[0]?.sent).toEqual(["first", "second"])
  255. }),
  256. )
  257. })
  258. test("serializes concurrent calls for one Session", async () => {
  259. const started = Deferred.makeUnsafe<void>()
  260. const release = Deferred.makeUnsafe<void>()
  261. const messages = queue<string | Uint8Array, AIError>()
  262. const sent: string[] = []
  263. const connector: WebSocketConnector = {
  264. open: () =>
  265. Effect.succeed({
  266. sendText: (message) =>
  267. Effect.gen(function* () {
  268. sent.push(message)
  269. if (message === "first") {
  270. yield* Deferred.succeed(started, undefined)
  271. yield* Deferred.await(release)
  272. }
  273. Queue.offerUnsafe(messages, `completed:${message}`)
  274. }),
  275. messages: Stream.fromQueue(messages),
  276. close: Queue.shutdown(messages).pipe(Effect.asVoid),
  277. }),
  278. }
  279. await run(
  280. connector,
  281. Effect.gen(function* () {
  282. const transport = yield* SessionModelTransport.Service
  283. const executor = transport.bind(session)
  284. const first = yield* collect(executor, exchange("first")).pipe(Effect.forkChild({ startImmediately: true }))
  285. yield* Deferred.await(started)
  286. const second = yield* collect(executor, exchange("second")).pipe(Effect.forkChild({ startImmediately: true }))
  287. yield* Effect.yieldNow
  288. expect(sent).toEqual(["first"])
  289. yield* Deferred.succeed(release, undefined)
  290. yield* Fiber.join(first)
  291. yield* Fiber.join(second)
  292. expect(sent).toEqual(["first", "second"])
  293. }),
  294. )
  295. })
  296. test("isolates connections and permits concurrency across Sessions", async () => {
  297. const started = queue<string>()
  298. const release = Deferred.makeUnsafe<void>()
  299. let opened = 0
  300. const connector: WebSocketConnector = {
  301. open: () =>
  302. Effect.gen(function* () {
  303. opened++
  304. const messages = yield* Queue.unbounded<string | Uint8Array, AIError>()
  305. return {
  306. sendText: (message) =>
  307. Effect.gen(function* () {
  308. Queue.offerUnsafe(started, message)
  309. yield* Deferred.await(release)
  310. Queue.offerUnsafe(messages, `completed:${message}`)
  311. }),
  312. messages: Stream.fromQueue(messages),
  313. close: Queue.shutdown(messages).pipe(Effect.asVoid),
  314. }
  315. }),
  316. }
  317. await run(
  318. connector,
  319. Effect.gen(function* () {
  320. const transport = yield* SessionModelTransport.Service
  321. const first = yield* collect(transport.bind(session), exchange("first")).pipe(
  322. Effect.forkChild({ startImmediately: true }),
  323. )
  324. const second = yield* collect(transport.bind(otherSession), exchange("second")).pipe(
  325. Effect.forkChild({ startImmediately: true }),
  326. )
  327. expect(new Set([yield* Queue.take(started), yield* Queue.take(started)])).toEqual(new Set(["first", "second"]))
  328. expect(opened).toBe(2)
  329. yield* Deferred.succeed(release, undefined)
  330. yield* Fiber.join(first)
  331. yield* Fiber.join(second)
  332. }),
  333. )
  334. })
  335. test("cancels a queued call without affecting the active exchange", async () => {
  336. const started = Deferred.makeUnsafe<void>()
  337. const release = Deferred.makeUnsafe<void>()
  338. const messages = queue<string | Uint8Array, AIError>()
  339. const sent: string[] = []
  340. const connector: WebSocketConnector = {
  341. open: () =>
  342. Effect.succeed({
  343. sendText: (message) =>
  344. Effect.gen(function* () {
  345. sent.push(message)
  346. yield* Deferred.succeed(started, undefined)
  347. yield* Deferred.await(release)
  348. Queue.offerUnsafe(messages, `completed:${message}`)
  349. }),
  350. messages: Stream.fromQueue(messages),
  351. close: Queue.shutdown(messages).pipe(Effect.asVoid),
  352. }),
  353. }
  354. await run(
  355. connector,
  356. Effect.gen(function* () {
  357. const transport = yield* SessionModelTransport.Service
  358. const executor = transport.bind(session)
  359. const active = yield* collect(executor, exchange("active")).pipe(Effect.forkChild({ startImmediately: true }))
  360. yield* Deferred.await(started)
  361. const queued = yield* collect(executor, exchange("queued")).pipe(Effect.forkChild({ startImmediately: true }))
  362. yield* Fiber.interrupt(queued)
  363. expect(sent).toEqual(["active"])
  364. yield* Deferred.succeed(release, undefined)
  365. expect(yield* Fiber.join(active)).toEqual(["completed:active"])
  366. }),
  367. )
  368. })
  369. test("closes the connection when an active exchange is interrupted", async () => {
  370. const started = Deferred.makeUnsafe<void>()
  371. const messages = queue<string | Uint8Array, AIError>()
  372. let closed = 0
  373. const connector: WebSocketConnector = {
  374. open: () =>
  375. Effect.succeed({
  376. sendText: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
  377. messages: Stream.fromQueue(messages),
  378. close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
  379. }),
  380. }
  381. await run(
  382. connector,
  383. Effect.gen(function* () {
  384. const transport = yield* SessionModelTransport.Service
  385. const fiber = yield* collect(transport.bind(session), exchange("first")).pipe(
  386. Effect.forkChild({ startImmediately: true }),
  387. )
  388. yield* Deferred.await(started)
  389. yield* Fiber.interrupt(fiber)
  390. expect(closed).toBe(1)
  391. }),
  392. )
  393. })
  394. test("closes an active exchange without waiting for its Session permit", async () => {
  395. const started = Deferred.makeUnsafe<void>()
  396. const messages = queue<string | Uint8Array, AIError>()
  397. let closed = 0
  398. const connector: WebSocketConnector = {
  399. open: () =>
  400. Effect.succeed({
  401. sendText: () => Deferred.succeed(started, undefined),
  402. messages: Stream.fromQueue(messages),
  403. close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
  404. }),
  405. }
  406. await run(
  407. connector,
  408. Effect.gen(function* () {
  409. const transport = yield* SessionModelTransport.Service
  410. const running = yield* collect(transport.bind(session), exchange("active")).pipe(
  411. Effect.forkChild({ startImmediately: true }),
  412. )
  413. yield* Deferred.await(started)
  414. yield* transport.close(session)
  415. const result = yield* Effect.result(Fiber.join(running))
  416. expect(result).toMatchObject({
  417. _tag: "Failure",
  418. failure: { reason: { _tag: "Transport", code: "close", delivery: "ambiguous" } },
  419. })
  420. expect(closed).toBe(1)
  421. }),
  422. )
  423. })
  424. test("times out an idle accepted request and poisons its socket", async () => {
  425. const started = Deferred.makeUnsafe<void>()
  426. const messages = queue<string | Uint8Array, AIError>()
  427. let closed = 0
  428. const connector: WebSocketConnector = {
  429. open: () =>
  430. Effect.succeed({
  431. sendText: () => Deferred.succeed(started, undefined),
  432. messages: Stream.fromQueue(messages),
  433. close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
  434. }),
  435. }
  436. await runWithTestClock(
  437. connector,
  438. Effect.gen(function* () {
  439. const transport = yield* SessionModelTransport.Service
  440. const running = yield* collect(transport.bind(session), exchange("idle")).pipe(
  441. Effect.forkChild({ startImmediately: true }),
  442. )
  443. yield* Deferred.await(started)
  444. yield* Effect.yieldNow
  445. yield* TestClock.adjust("5 minutes")
  446. const result = yield* Effect.result(Fiber.join(running))
  447. expect(result).toMatchObject({
  448. _tag: "Failure",
  449. failure: { reason: { _tag: "Transport", code: "idle-timeout", delivery: "ambiguous" } },
  450. })
  451. expect(closed).toBe(1)
  452. }),
  453. )
  454. })
  455. test("closes a newly opened connection when request creation is interrupted", async () => {
  456. const opened = Deferred.makeUnsafe<void>()
  457. const messages = queue<string | Uint8Array, AIError>()
  458. let closed = 0
  459. const connector: WebSocketConnector = {
  460. open: () =>
  461. Deferred.succeed(opened, undefined).pipe(
  462. Effect.as({
  463. sendText: () => Effect.void,
  464. messages: Stream.fromQueue(messages),
  465. close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
  466. }),
  467. ),
  468. }
  469. await run(
  470. connector,
  471. Effect.gen(function* () {
  472. const transport = yield* SessionModelTransport.Service
  473. const item = exchange("first")
  474. const fiber = yield* collect(transport.bind(session), {
  475. ...item,
  476. driver: { create: () => Effect.never, observe: item.driver.observe },
  477. }).pipe(Effect.forkChild({ startImmediately: true }))
  478. yield* Deferred.await(opened)
  479. yield* Fiber.interrupt(fiber)
  480. expect(closed).toBe(1)
  481. }),
  482. )
  483. })
  484. test("falls back once when connection setup fails before send", async () => {
  485. let fallbacks = 0
  486. const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
  487. await run(
  488. connector,
  489. Effect.gen(function* () {
  490. const transport = yield* SessionModelTransport.Service
  491. const result = yield* collect(
  492. transport.bind(session),
  493. exchange("first", {
  494. fallback: () => {
  495. fallbacks++
  496. return Stream.make("http")
  497. },
  498. }),
  499. )
  500. expect(result).toEqual(["http"])
  501. expect(fallbacks).toBe(1)
  502. }),
  503. )
  504. })
  505. test("does not fall back after an ambiguous send failure", async () => {
  506. const messages = queue<string | Uint8Array, AIError>()
  507. let fallbacks = 0
  508. let closed = 0
  509. const connector: WebSocketConnector = {
  510. open: () =>
  511. Effect.succeed({
  512. sendText: () => Effect.fail(error("send failed")),
  513. messages: Stream.fromQueue(messages),
  514. close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
  515. }),
  516. }
  517. await run(
  518. connector,
  519. Effect.gen(function* () {
  520. const transport = yield* SessionModelTransport.Service
  521. const result = yield* Effect.result(
  522. collect(
  523. transport.bind(session),
  524. exchange("first", {
  525. fallback: () => {
  526. fallbacks++
  527. return Stream.make("http")
  528. },
  529. }),
  530. ),
  531. )
  532. expect(result).toMatchObject({
  533. _tag: "Failure",
  534. failure: { reason: { _tag: "Transport", phase: "send", delivery: "ambiguous" } },
  535. })
  536. expect(fallbacks).toBe(0)
  537. expect(closed).toBe(1)
  538. }),
  539. )
  540. })
  541. test("rotates when handshake affinity or connection age changes", async () => {
  542. const fixture = automatic()
  543. await run(
  544. fixture.connector,
  545. Effect.gen(function* () {
  546. const transport = yield* SessionModelTransport.Service
  547. const executor = transport.bind(session)
  548. yield* collect(executor, exchange("first", { headers: { authorization: "one" } }))
  549. yield* collect(executor, exchange("second", { headers: { authorization: "one" } }))
  550. yield* collect(executor, exchange("third", { headers: { authorization: "two" } }))
  551. yield* Effect.sleep("5 millis")
  552. yield* collect(executor, exchange("fourth", { headers: { authorization: "two" }, rotateAfterMs: 1 }))
  553. expect(fixture.connections).toHaveLength(3)
  554. expect(fixture.connections.slice(0, 2).map((item) => item.closed)).toEqual([1, 1])
  555. }),
  556. )
  557. })
  558. test("poisons a socket that receives data while idle", async () => {
  559. const fixture = automatic()
  560. await run(
  561. fixture.connector,
  562. Effect.gen(function* () {
  563. const transport = yield* SessionModelTransport.Service
  564. const executor = transport.bind(session)
  565. yield* collect(executor, exchange("first"))
  566. const connection = fixture.connections[0]
  567. if (!connection) throw new Error("Expected connection")
  568. Queue.offerUnsafe(connection.messages, "late")
  569. yield* Effect.yieldNow
  570. yield* collect(executor, exchange("second"))
  571. expect(fixture.connections).toHaveLength(2)
  572. expect(fixture.connections[0]?.closed).toBe(1)
  573. }),
  574. )
  575. })
  576. test("poisons instead of dropping data when the inbound queue overflows", async () => {
  577. const messages = queue<string | Uint8Array, AIError>()
  578. let closed = 0
  579. const connector: WebSocketConnector = {
  580. open: () =>
  581. Effect.succeed({
  582. sendText: () =>
  583. Effect.sync(() => {
  584. for (let index = 0; index <= 129; index++) Queue.offerUnsafe(messages, `frame:${index}`)
  585. }),
  586. messages: Stream.fromQueue(messages),
  587. close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
  588. }),
  589. }
  590. await run(
  591. connector,
  592. Effect.gen(function* () {
  593. const transport = yield* SessionModelTransport.Service
  594. const item = exchange("first")
  595. const result = yield* Effect.result(
  596. collect(transport.bind(session), {
  597. ...item,
  598. driver: {
  599. create: item.driver.create,
  600. observe: (_create, frame) => Effect.sleep("1 millis").pipe(Effect.as({ type: "frame" as const, frame })),
  601. },
  602. }),
  603. )
  604. expect(result).toMatchObject({
  605. _tag: "Failure",
  606. failure: { reason: { _tag: "Transport", code: "queue-overflow", delivery: "accepted" } },
  607. })
  608. expect(closed).toBe(1)
  609. }),
  610. )
  611. })
  612. test("poisons unsupported binary frames after provider observation", async () => {
  613. const messages = queue<string | Uint8Array, AIError>()
  614. const closed = Deferred.makeUnsafe<void>()
  615. const connector: WebSocketConnector = {
  616. open: () =>
  617. Effect.succeed({
  618. sendText: () => Effect.sync(() => Queue.offerUnsafe(messages, new Uint8Array([1]))).pipe(Effect.asVoid),
  619. messages: Stream.fromQueue(messages),
  620. close: Deferred.succeed(closed, undefined).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
  621. }),
  622. }
  623. await run(
  624. connector,
  625. Effect.gen(function* () {
  626. const transport = yield* SessionModelTransport.Service
  627. const result = yield* Effect.result(collect(transport.bind(session), exchange("first")))
  628. expect(result).toMatchObject({
  629. _tag: "Failure",
  630. failure: { reason: { _tag: "Transport", code: "message", delivery: "accepted" } },
  631. })
  632. yield* Deferred.await(closed)
  633. }),
  634. )
  635. })
  636. test("closes individual and all owned connections", async () => {
  637. const fixture = automatic()
  638. await run(
  639. fixture.connector,
  640. Effect.gen(function* () {
  641. const transport = yield* SessionModelTransport.Service
  642. yield* collect(transport.bind(session), exchange("first"))
  643. yield* collect(transport.bind(otherSession), exchange("second"))
  644. yield* transport.close(session)
  645. expect(fixture.connections.map((item) => item.closed)).toEqual([1, 0])
  646. yield* transport.closeAll
  647. expect(fixture.connections.map((item) => item.closed)).toEqual([1, 1])
  648. }),
  649. )
  650. })
  651. test("closes owned connections when the Location scope ends", async () => {
  652. const fixture = automatic()
  653. await run(
  654. fixture.connector,
  655. Effect.gen(function* () {
  656. const transport = yield* SessionModelTransport.Service
  657. yield* collect(transport.bind(session), exchange("first"))
  658. expect(fixture.connections[0]?.closed).toBe(0)
  659. }),
  660. )
  661. expect(fixture.connections[0]?.closed).toBe(1)
  662. })
  663. test("records metadata-only lifecycle metrics", async () => {
  664. const fixture = automatic()
  665. await run(
  666. fixture.connector,
  667. Effect.gen(function* () {
  668. const transport = yield* SessionModelTransport.Service
  669. const executor = transport.bind(session)
  670. yield* collect(executor, exchange("first", { headers: { authorization: "secret-one" } }))
  671. yield* collect(executor, exchange("second", { headers: { authorization: "secret-one" } }))
  672. yield* collect(executor, exchange("third", { headers: { authorization: "secret-two" } }))
  673. const snapshots = yield* Metric.snapshot
  674. const lifecycle = snapshots.filter((item) => item.id === "opencode_session_websocket_events_total")
  675. const names = new Set(lifecycle.map((item) => item.attributes?.event))
  676. expect(Array.from(names)).toEqual(
  677. expect.arrayContaining(["connect", "reuse", "rotation", "reconnect", "send", "terminal"]),
  678. )
  679. expect(JSON.stringify(lifecycle)).not.toContain("secret-one")
  680. expect(JSON.stringify(lifecycle)).not.toContain("secret-two")
  681. }),
  682. )
  683. })
  684. })