session-messages.test.ts 5.7 KB

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