effect.test.ts 11 KB

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