effect.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import { expect, test } from "bun:test"
  2. import { DateTime, Effect, Stream } from "effect"
  3. import { HttpClient, HttpClientResponse } from "effect/unstable/http"
  4. import {
  5. AbsolutePath,
  6. Agent,
  7. Event,
  8. Location,
  9. Model,
  10. OpenCode,
  11. Prompt,
  12. Session,
  13. SessionMessage,
  14. } from "../src/effect/index"
  15. const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
  16. test("health.get decodes the readiness response", async () => {
  17. const httpClient = HttpClient.make((request) =>
  18. Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))),
  19. )
  20. const result = await Effect.gen(function* () {
  21. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  22. return yield* client.health.get()
  23. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  24. expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
  25. })
  26. test("session.get returns the decoded Effect projection", async () => {
  27. const httpClient = HttpClient.make((request) =>
  28. Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
  29. )
  30. const result = await Effect.gen(function* () {
  31. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  32. return yield* client.session.get({ sessionID: Session.ID.make("ses_test") })
  33. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  34. expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
  35. })
  36. test("session instructions methods use the public HTTP contract", async () => {
  37. const requests: Array<{ method: string; url: string; body?: unknown }> = []
  38. const instructions = [{ key: "review-notes", value: { text: "Check the diff", priority: 1 } }]
  39. const httpClient = HttpClient.make((request) => {
  40. requests.push({
  41. method: request.method,
  42. url: request.url,
  43. body: request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined,
  44. })
  45. return Effect.succeed(
  46. HttpClientResponse.fromWeb(
  47. request,
  48. request.method === "GET" ? Response.json({ data: instructions }) : new Response(null, { status: 204 }),
  49. ),
  50. )
  51. })
  52. const result = await Effect.gen(function* () {
  53. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  54. const listed = yield* client.session.instructions.entry.list({ sessionID: Session.ID.make("ses_test") })
  55. yield* client.session.instructions.entry.put({
  56. sessionID: Session.ID.make("ses_test"),
  57. key: "review-notes",
  58. value: instructions[0].value,
  59. })
  60. yield* client.session.instructions.entry.remove({
  61. sessionID: Session.ID.make("ses_test"),
  62. key: "review-notes",
  63. })
  64. return listed
  65. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  66. expect(result).toEqual(instructions)
  67. expect(requests).toEqual([
  68. {
  69. method: "GET",
  70. url: "http://localhost:3000/api/session/ses_test/instructions/entries",
  71. body: undefined,
  72. },
  73. {
  74. method: "PUT",
  75. url: "http://localhost:3000/api/session/ses_test/instructions/entries/review-notes",
  76. body: { value: { text: "Check the diff", priority: 1 } },
  77. },
  78. {
  79. method: "DELETE",
  80. url: "http://localhost:3000/api/session/ses_test/instructions/entries/review-notes",
  81. body: undefined,
  82. },
  83. ])
  84. })
  85. test("event.subscribe exposes and decodes the native Effect event stream", async () => {
  86. const httpClient = HttpClient.make((request) =>
  87. Effect.succeed(
  88. HttpClientResponse.fromWeb(
  89. request,
  90. new Response(
  91. `data: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` +
  92. `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
  93. { headers: { "content-type": "text/event-stream" } },
  94. ),
  95. ),
  96. ),
  97. )
  98. const events = await Effect.gen(function* () {
  99. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  100. return yield* client.event.subscribe().pipe(Stream.runCollect)
  101. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  102. expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"])
  103. const durable = events[1]
  104. if (durable?.type !== "session.model.selected") throw new Error("Expected model event")
  105. expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
  106. expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
  107. })
  108. test("event.subscribe terminates on Effect protocol decode failures", async () => {
  109. const httpClient = HttpClient.make((request) =>
  110. Effect.succeed(
  111. HttpClientResponse.fromWeb(
  112. request,
  113. new Response(`data: {"type":"server.connected"}\n\n`, {
  114. headers: { "content-type": "text/event-stream" },
  115. }),
  116. ),
  117. ),
  118. )
  119. const error = await Effect.gen(function* () {
  120. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  121. return yield* client.event.subscribe().pipe(Stream.runCollect, Effect.flip)
  122. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  123. expect(error._tag).toBe("ClientError")
  124. })
  125. test("session methods retain decoded Effect inputs and outputs", async () => {
  126. const logQueries: Array<Record<string, string>> = []
  127. const httpClient = HttpClient.make((request) => {
  128. const url = request.url
  129. if (url.includes("/log")) {
  130. logQueries.push(Object.fromEntries(request.urlParams.params))
  131. return Effect.succeed(
  132. HttpClientResponse.fromWeb(
  133. request,
  134. new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(synced)}\n\n`, {
  135. headers: { "content-type": "text/event-stream" },
  136. }),
  137. ),
  138. )
  139. }
  140. if (url.includes("/prompt")) {
  141. return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
  142. }
  143. if (url.endsWith("/compact")) {
  144. return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(compactionAdmission)))
  145. }
  146. if (url.includes("/context")) {
  147. return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
  148. }
  149. if (url.includes("/message/")) {
  150. return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage })))
  151. }
  152. if (url.endsWith("/api/session/active")) {
  153. return Effect.succeed(
  154. HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
  155. )
  156. }
  157. if (request.method === "POST" && url.endsWith("/api/session")) {
  158. return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
  159. }
  160. if (request.method === "POST") {
  161. return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
  162. }
  163. return Effect.succeed(
  164. HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
  165. )
  166. })
  167. const result = await Effect.gen(function* () {
  168. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  169. const page = yield* client.session.list({ limit: 10 })
  170. const active = yield* client.session.active()
  171. const created = yield* client.session.create({
  172. location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
  173. })
  174. yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
  175. yield* client.session.switchModel({
  176. sessionID: Session.ID.make("ses_test"),
  177. model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
  178. })
  179. const admitted = yield* client.session.prompt({
  180. sessionID: Session.ID.make("ses_test"),
  181. text: "Hello",
  182. resume: false,
  183. })
  184. yield* client.session.compact({ sessionID: Session.ID.make("ses_test") })
  185. yield* client.session.wait({ sessionID: Session.ID.make("ses_test") })
  186. const context = yield* client.session.context({ sessionID: Session.ID.make("ses_test") })
  187. const log = yield* client.session
  188. .log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) })
  189. .pipe(Stream.runCollect)
  190. yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
  191. const message = yield* client.session.message({
  192. sessionID: Session.ID.make("ses_test"),
  193. messageID: SessionMessage.ID.make("msg_model"),
  194. })
  195. return { page, active, created, admitted, context, log, message }
  196. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  197. expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
  198. expect(result.active).toEqual({ ses_test: { type: "running" } })
  199. expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
  200. expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
  201. expect(result.created.id).toBe("ses_test")
  202. expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
  203. expect(Object.getPrototypeOf(result.admitted.data)).toBe(Object.prototype)
  204. expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
  205. expect(result.context).toEqual([])
  206. expect(logQueries[0]).toEqual({ after: "0" })
  207. const logged = Array.from(result.log)
  208. expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
  209. expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
  210. 1_717_171_717_000,
  211. )
  212. expect(logged.at(-1)).toEqual(synced)
  213. expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
  214. })
  215. test("session.log retains the typed SessionNotFoundError", async () => {
  216. const httpClient = HttpClient.make((request) =>
  217. Effect.succeed(
  218. HttpClientResponse.fromWeb(
  219. request,
  220. Response.json(
  221. { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
  222. { status: 404 },
  223. ),
  224. ),
  225. ),
  226. )
  227. const error = await Effect.gen(function* () {
  228. const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
  229. return yield* client.session.log({ sessionID: Session.ID.make("ses_missing") }).pipe(Stream.runCollect, Effect.flip)
  230. }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
  231. expect(error._tag).toBe("SessionNotFoundError")
  232. })
  233. const session = {
  234. data: {
  235. id: "ses_test",
  236. projectID: "project",
  237. cost: 0,
  238. tokens: {
  239. input: 1,
  240. output: 2,
  241. reasoning: 3,
  242. cache: { read: 4, write: 5 },
  243. },
  244. time: {
  245. created: 1_717_171_717_000,
  246. updated: 1_717_171_717_000,
  247. },
  248. title: "Test",
  249. location: { directory: "/tmp/project" },
  250. },
  251. }
  252. const admission = {
  253. data: {
  254. id: "msg_test",
  255. sessionID: "ses_test",
  256. type: "user",
  257. data: { text: "Hello" },
  258. delivery: "steer",
  259. timeCreated: 1_717_171_717_000,
  260. },
  261. }
  262. const compactionAdmission = {
  263. data: {
  264. type: "compaction",
  265. id: "msg_compaction",
  266. sessionID: "ses_test",
  267. timeCreated: 1_717_171_717_000,
  268. },
  269. }
  270. const modelSwitchedMessage = {
  271. id: "msg_model",
  272. type: "model-switched",
  273. time: { created: 1_717_171_717_000 },
  274. model: { id: "claude", providerID: "anthropic" },
  275. }
  276. const modelSwitchedEvent = {
  277. id: "evt_model",
  278. created: 1_717_171_717_000,
  279. type: "session.model.selected",
  280. durable: { aggregateID: "ses_test", seq: 1, version: 1 },
  281. data: {
  282. sessionID: "ses_test",
  283. model: { id: "claude", providerID: "anthropic" },
  284. },
  285. }