effect.test.ts 7.7 KB

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