session-messages.test.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { SessionV1 } from "@opencode-ai/core/v1/session"
  3. import { Effect, Layer } from "effect"
  4. import { HttpClientResponse } from "effect/unstable/http"
  5. import { Session as SessionNs } from "@/session/session"
  6. import { MessageV2 } from "../../src/session/message-v2"
  7. import { MessageID, PartID, type SessionID } from "../../src/session/schema"
  8. import { disposeAllInstances, TestInstance } from "../fixture/fixture"
  9. import { testEffect } from "../lib/effect"
  10. import { ProviderV2 } from "@opencode-ai/core/provider"
  11. import { ModelV2 } from "@opencode-ai/core/model"
  12. import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
  13. const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
  14. const model = {
  15. providerID: ProviderV2.ID.make("test"),
  16. modelID: ModelV2.ID.make("test"),
  17. }
  18. afterEach(async () => {
  19. await disposeAllInstances()
  20. })
  21. const withoutWatcher = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
  22. if (process.platform !== "win32") return effect
  23. return Effect.acquireUseRelease(
  24. Effect.sync(() => {
  25. const previous = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
  26. process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true"
  27. return previous
  28. }),
  29. () => effect,
  30. (previous) =>
  31. Effect.sync(() => {
  32. if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
  33. else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = previous
  34. }),
  35. )
  36. }
  37. const sessionScoped = Effect.acquireRelease(SessionNs.use.create({}), (session) =>
  38. SessionNs.use.remove(session.id).pipe(Effect.ignore),
  39. )
  40. const fill = Effect.fn("SessionMessagesTest.fill")(function* (
  41. sessionID: SessionID,
  42. count: number,
  43. time = (i: number) => Date.now() + i,
  44. ) {
  45. const session = yield* SessionNs.Service
  46. return yield* Effect.forEach(
  47. Array.from({ length: count }, (_, i) => i),
  48. (i) =>
  49. Effect.gen(function* () {
  50. const id = MessageID.ascending()
  51. yield* session.updateMessage({
  52. id,
  53. sessionID,
  54. role: "user",
  55. time: { created: time(i) },
  56. agent: "test",
  57. model,
  58. tools: {},
  59. } satisfies SessionV1.User)
  60. yield* session.updatePart({
  61. id: PartID.ascending(),
  62. sessionID,
  63. messageID: id,
  64. type: "text",
  65. text: `m${i}`,
  66. } satisfies SessionV1.TextPart)
  67. return id
  68. }),
  69. )
  70. })
  71. function request(path: string) {
  72. return TestInstance.pipe(Effect.flatMap((test) => requestInDirectory(path, test.directory)))
  73. }
  74. function json<T>(response: HttpClientResponse.HttpClientResponse) {
  75. return response.json.pipe(Effect.map((body) => body as T))
  76. }
  77. describe("session messages endpoint", () => {
  78. it.instance(
  79. "returns cursor headers for older pages",
  80. withoutWatcher(
  81. Effect.gen(function* () {
  82. const session = yield* sessionScoped
  83. const ids = yield* fill(session.id, 5)
  84. const a = yield* request(`/session/${session.id}/message?limit=2`)
  85. expect(a.status).toBe(200)
  86. const aBody = yield* json<SessionV1.WithParts[]>(a)
  87. expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2))
  88. const cursor = a.headers["x-next-cursor"]
  89. expect(cursor).toBeTruthy()
  90. expect(a.headers["link"]).toContain('rel="next"')
  91. const b = yield* request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`)
  92. expect(b.status).toBe(200)
  93. const bBody = yield* json<SessionV1.WithParts[]>(b)
  94. expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
  95. }),
  96. ),
  97. { git: true },
  98. )
  99. it.instance(
  100. "keeps full-history responses when limit is omitted",
  101. withoutWatcher(
  102. Effect.gen(function* () {
  103. const session = yield* sessionScoped
  104. const ids = yield* fill(session.id, 3)
  105. const res = yield* request(`/session/${session.id}/message`)
  106. expect(res.status).toBe(200)
  107. const body = yield* json<SessionV1.WithParts[]>(res)
  108. expect(body.map((item) => item.info.id)).toEqual(ids)
  109. }),
  110. ),
  111. { git: true },
  112. )
  113. it.instance(
  114. "rejects invalid cursors and missing sessions",
  115. withoutWatcher(
  116. Effect.gen(function* () {
  117. const session = yield* sessionScoped
  118. const bad = yield* request(`/session/${session.id}/message?limit=2&before=bad`)
  119. expect(bad.status).toBe(400)
  120. const miss = yield* request(`/session/ses_missing/message?limit=2`)
  121. expect(miss.status).toBe(404)
  122. }),
  123. ),
  124. { git: true },
  125. )
  126. it.instance(
  127. "does not truncate large legacy limit requests",
  128. withoutWatcher(
  129. Effect.gen(function* () {
  130. const session = yield* sessionScoped
  131. yield* fill(session.id, 520)
  132. const res = yield* request(`/session/${session.id}/message?limit=510`)
  133. expect(res.status).toBe(200)
  134. const body = yield* json<SessionV1.WithParts[]>(res)
  135. expect(body).toHaveLength(510)
  136. }),
  137. ),
  138. { git: true },
  139. )
  140. it.instance(
  141. "accepts directory query used by workspace routing",
  142. withoutWatcher(
  143. Effect.gen(function* () {
  144. const tmp = yield* TestInstance
  145. const session = yield* sessionScoped
  146. yield* fill(session.id, 1)
  147. const res = yield* request(
  148. `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.directory)}`,
  149. )
  150. expect(res.status).toBe(200)
  151. const body = yield* json<unknown[]>(res)
  152. expect(Array.isArray(body)).toBe(true)
  153. expect(body).toHaveLength(1)
  154. }),
  155. ),
  156. { git: true },
  157. )
  158. })