effect.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import { expect, test } from "bun:test"
  2. import { DateTime, Effect, Stream } from "effect"
  3. import { HttpClient, HttpClientResponse } from "effect/unstable/http"
  4. import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
  5. test("sessions.get returns the decoded Effect projection", async () => {
  6. const httpClient = HttpClient.make((request) =>
  7. Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
  8. )
  9. const result = await Effect.gen(function* () {
  10. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  11. return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") })
  12. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  13. expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
  14. })
  15. test("events.subscribe exposes and decodes the native Effect event stream", async () => {
  16. const httpClient = HttpClient.make((request) =>
  17. Effect.succeed(
  18. HttpClientResponse.fromWeb(
  19. request,
  20. new Response(
  21. `data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
  22. `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
  23. { headers: { "content-type": "text/event-stream" } },
  24. ),
  25. ),
  26. ),
  27. )
  28. const events = await Effect.gen(function* () {
  29. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  30. return yield* client.events.subscribe().pipe(Stream.runCollect)
  31. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  32. expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
  33. const durable = events[1]
  34. if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
  35. expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
  36. expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
  37. })
  38. test("events.subscribe terminates on Effect protocol decode failures", async () => {
  39. const httpClient = HttpClient.make((request) =>
  40. Effect.succeed(
  41. HttpClientResponse.fromWeb(
  42. request,
  43. new Response(`data: {"type":"server.connected"}\n\n`, {
  44. headers: { "content-type": "text/event-stream" },
  45. }),
  46. ),
  47. ),
  48. )
  49. const error = await Effect.gen(function* () {
  50. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  51. return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
  52. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  53. expect(error._tag).toBe("ClientError")
  54. })
  55. test("session methods retain decoded Effect inputs and outputs", async () => {
  56. const historyQueries: Array<Record<string, string>> = []
  57. let historyPage = 0
  58. const httpClient = HttpClient.make((request) => {
  59. const url = request.url
  60. if (url.includes("/event")) {
  61. return Effect.succeed(
  62. HttpClientResponse.fromWeb(
  63. request,
  64. new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
  65. headers: { "content-type": "text/event-stream" },
  66. }),
  67. ),
  68. )
  69. }
  70. if (url.includes("/history")) {
  71. historyPage++
  72. historyQueries.push(Object.fromEntries(request.urlParams.params))
  73. return Effect.succeed(
  74. HttpClientResponse.fromWeb(
  75. request,
  76. Response.json(
  77. historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
  78. ),
  79. ),
  80. )
  81. }
  82. if (url.includes("/prompt")) {
  83. return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
  84. }
  85. if (url.includes("/context")) {
  86. return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
  87. }
  88. if (url.includes("/message/")) {
  89. return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage })))
  90. }
  91. if (url.endsWith("/api/session/active")) {
  92. return Effect.succeed(
  93. HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
  94. )
  95. }
  96. if (request.method === "POST" && url.endsWith("/api/session")) {
  97. return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
  98. }
  99. if (request.method === "POST") {
  100. return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
  101. }
  102. return Effect.succeed(
  103. HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
  104. )
  105. })
  106. const result = await Effect.gen(function* () {
  107. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  108. const page = yield* client.sessions.list({ limit: 10 })
  109. const active = yield* client.sessions.active()
  110. const created = yield* client.sessions.create({
  111. location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
  112. })
  113. yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
  114. yield* client.sessions.switchModel({
  115. sessionID: Session.ID.make("ses_test"),
  116. model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
  117. })
  118. const admitted = yield* client.sessions.prompt({
  119. sessionID: Session.ID.make("ses_test"),
  120. prompt: Prompt.make({ text: "Hello" }),
  121. resume: false,
  122. })
  123. yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
  124. yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
  125. const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
  126. const history = yield* client.sessions.history({
  127. sessionID: Session.ID.make("ses_test"),
  128. after: 0,
  129. limit: 1,
  130. })
  131. const historyNext = history.hasMore
  132. ? yield* client.sessions.history({
  133. sessionID: Session.ID.make("ses_test"),
  134. after: history.data.at(-1)?.durable?.seq,
  135. limit: 2,
  136. })
  137. : undefined
  138. const events = yield* client.sessions
  139. .events({ sessionID: Session.ID.make("ses_test"), after: 0 })
  140. .pipe(Stream.runCollect)
  141. yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") })
  142. const message = yield* client.sessions.message({
  143. sessionID: Session.ID.make("ses_test"),
  144. messageID: SessionMessage.ID.make("msg_model"),
  145. })
  146. return { page, active, created, admitted, context, history, historyNext, events, message }
  147. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  148. expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
  149. expect(result.active).toEqual({ ses_test: { type: "running" } })
  150. expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
  151. expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
  152. expect(result.created.id).toBe("ses_test")
  153. expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
  154. expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
  155. expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
  156. expect(result.context).toEqual([])
  157. expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
  158. expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
  159. expect(result.historyNext).toEqual({ data: [], hasMore: false })
  160. expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
  161. expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
  162. expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
  163. expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
  164. })
  165. test("sessions.history retains the typed SessionNotFoundError", async () => {
  166. const httpClient = HttpClient.make((request) =>
  167. Effect.succeed(
  168. HttpClientResponse.fromWeb(
  169. request,
  170. Response.json(
  171. { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
  172. { status: 404 },
  173. ),
  174. ),
  175. ),
  176. )
  177. const error = await Effect.gen(function* () {
  178. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  179. return yield* client.sessions
  180. .history({
  181. sessionID: Session.ID.make("ses_missing"),
  182. })
  183. .pipe(Effect.flip)
  184. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  185. expect(error._tag).toBe("SessionNotFoundError")
  186. })
  187. const session = {
  188. data: {
  189. id: "ses_test",
  190. projectID: "project",
  191. cost: 0,
  192. tokens: {
  193. input: 1,
  194. output: 2,
  195. reasoning: 3,
  196. cache: { read: 4, write: 5 },
  197. },
  198. time: {
  199. created: 1_717_171_717_000,
  200. updated: 1_717_171_717_000,
  201. },
  202. title: "Test",
  203. location: { directory: "/tmp/project" },
  204. },
  205. }
  206. const admission = {
  207. data: {
  208. admittedSeq: 0,
  209. id: "msg_test",
  210. sessionID: "ses_test",
  211. prompt: { text: "Hello" },
  212. delivery: "steer",
  213. timeCreated: 1_717_171_717_000,
  214. },
  215. }
  216. const modelSwitchedMessage = {
  217. id: "msg_model",
  218. type: "model-switched",
  219. time: { created: 1_717_171_717_000 },
  220. model: { id: "claude", providerID: "anthropic" },
  221. }
  222. const modelSwitchedEvent = {
  223. id: "evt_model",
  224. type: "session.next.model.switched",
  225. durable: { aggregateID: "ses_test", seq: 1, version: 1 },
  226. data: {
  227. timestamp: 1_717_171_717_000,
  228. sessionID: "ses_test",
  229. messageID: "msg_model",
  230. model: { id: "claude", providerID: "anthropic" },
  231. },
  232. }