session-list.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  4. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  5. import { Database } from "@kirincode-ai/core/database/database"
  6. import { SessionProjector } from "@kirincode-ai/core/session/projector"
  7. import { Session as SessionNs } from "@/session/session"
  8. import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
  9. import { mkdir } from "fs/promises"
  10. import path from "path"
  11. import { SessionTable } from "@kirincode-ai/core/session/sql"
  12. import { eq } from "drizzle-orm"
  13. import { testEffect } from "../lib/effect"
  14. import { RuntimeFlags } from "@/effect/runtime-flags"
  15. const layer = (experimentalWorkspaces: boolean) =>
  16. AppNodeBuilder.build(LayerNode.group([Database.node, SessionNs.node, SessionProjector.node]), [
  17. [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })],
  18. ])
  19. const it = testEffect(layer(false))
  20. const itWorkspaces = testEffect(layer(true))
  21. const withSession = (input?: Parameters<SessionNs.Interface["create"]>[0]) =>
  22. Effect.acquireRelease(SessionNs.use.create(input), (created) =>
  23. SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)),
  24. )
  25. afterEach(async () => {
  26. await disposeAllInstances()
  27. })
  28. describe("session.list", () => {
  29. it.instance(
  30. "does not filter by directory when directory is omitted",
  31. () =>
  32. Effect.gen(function* () {
  33. const test = yield* TestInstance
  34. yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "kirincode"), { recursive: true }))
  35. yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
  36. const root = yield* withSession({ title: "root" })
  37. const parent = yield* withSession({ title: "parent" }).pipe(
  38. provideInstance(path.join(test.directory, "packages")),
  39. )
  40. const current = yield* withSession({ title: "current" }).pipe(
  41. provideInstance(path.join(test.directory, "packages", "kirincode")),
  42. )
  43. const sibling = yield* withSession({ title: "sibling" }).pipe(
  44. provideInstance(path.join(test.directory, "packages", "app")),
  45. )
  46. const ids = (yield* SessionNs.use.list()).map((session) => session.id)
  47. expect(ids).toContain(root.id)
  48. expect(ids).toContain(parent.id)
  49. expect(ids).toContain(current.id)
  50. expect(ids).toContain(sibling.id)
  51. }),
  52. { git: true },
  53. )
  54. it.instance(
  55. "filters by directory when directory is provided",
  56. () =>
  57. Effect.gen(function* () {
  58. const test = yield* TestInstance
  59. yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "kirincode"), { recursive: true }))
  60. yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
  61. const root = yield* withSession({ title: "root" })
  62. const parent = yield* withSession({ title: "parent" }).pipe(
  63. provideInstance(path.join(test.directory, "packages")),
  64. )
  65. const current = yield* withSession({ title: "current" }).pipe(
  66. provideInstance(path.join(test.directory, "packages", "kirincode")),
  67. )
  68. const sibling = yield* withSession({ title: "sibling" }).pipe(
  69. provideInstance(path.join(test.directory, "packages", "app")),
  70. )
  71. const ids = (yield* SessionNs.Service.use((session) =>
  72. session.list({ directory: path.join(test.directory, "packages", "kirincode") }),
  73. )).map((session) => session.id)
  74. expect(ids).not.toContain(root.id)
  75. expect(ids).not.toContain(parent.id)
  76. expect(ids).toContain(current.id)
  77. expect(ids).not.toContain(sibling.id)
  78. }),
  79. { git: true },
  80. )
  81. itWorkspaces.instance(
  82. "filters by directory when experimental workspaces are enabled",
  83. () =>
  84. Effect.gen(function* () {
  85. const test = yield* TestInstance
  86. yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "kirincode"), { recursive: true }))
  87. yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
  88. const current = yield* withSession({ title: "current" }).pipe(
  89. provideInstance(path.join(test.directory, "packages", "kirincode")),
  90. )
  91. const sibling = yield* withSession({ title: "sibling" }).pipe(
  92. provideInstance(path.join(test.directory, "packages", "app")),
  93. )
  94. const ids = (yield* SessionNs.Service.use((session) =>
  95. session.list({ directory: path.join(test.directory, "packages", "kirincode") }),
  96. )).map((session) => session.id)
  97. expect(ids).toContain(current.id)
  98. expect(ids).not.toContain(sibling.id)
  99. }),
  100. { git: true },
  101. )
  102. it.instance(
  103. "matches a session regardless of directory separator on Windows",
  104. () =>
  105. Effect.gen(function* () {
  106. if (process.platform !== "win32") return
  107. const test = yield* TestInstance
  108. const dir = path.join(test.directory, "packages", "kirincode")
  109. yield* Effect.promise(() => mkdir(dir, { recursive: true }))
  110. const created = yield* withSession({ title: "separator" }).pipe(provideInstance(dir))
  111. // A forward-slash query (e.g. from the SDK/HTTP layer) must still find it —
  112. // this is the regression: backslash-stored vs forward-slash-queried.
  113. const forwardIDs = (yield* SessionNs.Service.use((session) =>
  114. session.list({ directory: dir.replaceAll("\\", "/") }),
  115. )).map((session) => session.id)
  116. expect(forwardIDs).toContain(created.id)
  117. // The native form must keep matching too.
  118. const nativeIDs = (yield* SessionNs.Service.use((session) => session.list({ directory: dir }))).map(
  119. (session) => session.id,
  120. )
  121. expect(nativeIDs).toContain(created.id)
  122. }),
  123. { git: true },
  124. )
  125. it.instance(
  126. "filters by path and ignores directory when path is provided",
  127. () =>
  128. Effect.gen(function* () {
  129. const test = yield* TestInstance
  130. yield* Effect.promise(() =>
  131. mkdir(path.join(test.directory, "packages", "kirincode", "src", "deep"), { recursive: true }),
  132. )
  133. yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
  134. const parent = yield* withSession({ title: "parent" }).pipe(
  135. provideInstance(path.join(test.directory, "packages", "kirincode")),
  136. )
  137. const current = yield* withSession({ title: "current" }).pipe(
  138. provideInstance(path.join(test.directory, "packages", "kirincode", "src")),
  139. )
  140. const deeper = yield* withSession({ title: "deeper" }).pipe(
  141. provideInstance(path.join(test.directory, "packages", "kirincode", "src", "deep")),
  142. )
  143. const sibling = yield* withSession({ title: "sibling" }).pipe(
  144. provideInstance(path.join(test.directory, "packages", "app")),
  145. )
  146. const pathIDs = (yield* SessionNs.Service.use((session) =>
  147. session.list({
  148. directory: path.join(test.directory, "packages", "app"),
  149. path: "packages/kirincode/src",
  150. }),
  151. )).map((session) => session.id)
  152. expect(pathIDs).not.toContain(parent.id)
  153. expect(pathIDs).toContain(current.id)
  154. expect(pathIDs).toContain(deeper.id)
  155. expect(pathIDs).not.toContain(sibling.id)
  156. if (process.platform === "win32") {
  157. const windowsPathIDs = (yield* SessionNs.Service.use((session) =>
  158. session.list({ path: "packages\\opencode\\src" }),
  159. )).map((session) => session.id)
  160. expect(windowsPathIDs).toContain(current.id)
  161. expect(windowsPathIDs).toContain(deeper.id)
  162. }
  163. }),
  164. { git: true },
  165. )
  166. it.instance(
  167. "falls back to directory when filtering legacy sessions without path",
  168. () =>
  169. Effect.gen(function* () {
  170. const test = yield* TestInstance
  171. yield* Effect.promise(() =>
  172. mkdir(path.join(test.directory, "packages", "kirincode", "src"), { recursive: true }),
  173. )
  174. yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
  175. const current = yield* withSession({ title: "legacy-current" }).pipe(
  176. provideInstance(path.join(test.directory, "packages", "kirincode", "src")),
  177. )
  178. const sibling = yield* withSession({ title: "legacy-sibling" }).pipe(
  179. provideInstance(path.join(test.directory, "packages", "app")),
  180. )
  181. const { db } = yield* Database.Service
  182. yield* db
  183. .update(SessionTable)
  184. .set({ path: null })
  185. .where(eq(SessionTable.id, current.id))
  186. .run()
  187. .pipe(Effect.orDie)
  188. yield* db
  189. .update(SessionTable)
  190. .set({ path: null })
  191. .where(eq(SessionTable.id, sibling.id))
  192. .run()
  193. .pipe(Effect.orDie)
  194. const pathIDs = (yield* SessionNs.Service.use((session) =>
  195. session.list({
  196. directory: path.join(test.directory, "packages", "kirincode", "src"),
  197. path: "packages/kirincode/src",
  198. }),
  199. )).map((session) => session.id)
  200. expect(pathIDs).toContain(current.id)
  201. expect(pathIDs).not.toContain(sibling.id)
  202. }),
  203. { git: true },
  204. )
  205. it.instance(
  206. "filters root sessions",
  207. () =>
  208. Effect.gen(function* () {
  209. const root = yield* withSession({ title: "root-session" })
  210. const child = yield* withSession({ title: "child-session", parentID: root.id })
  211. const sessions = yield* SessionNs.use.list({ roots: true })
  212. const ids = sessions.map((session) => session.id)
  213. expect(ids).toContain(root.id)
  214. expect(ids).not.toContain(child.id)
  215. }),
  216. { git: true },
  217. )
  218. it.instance(
  219. "filters by start time",
  220. () =>
  221. Effect.gen(function* () {
  222. yield* withSession({ title: "new-session" })
  223. const sessions = yield* SessionNs.Service.use((session) => session.list({ start: Date.now() + 86400000 }))
  224. expect(sessions.length).toBe(0)
  225. }),
  226. { git: true },
  227. )
  228. it.instance(
  229. "filters by search term",
  230. () =>
  231. Effect.gen(function* () {
  232. yield* withSession({ title: "unique-search-term-abc" })
  233. yield* withSession({ title: "other-session-xyz" })
  234. const sessions = yield* SessionNs.use.list({ search: "unique-search" })
  235. const titles = sessions.map((session) => session.title)
  236. expect(titles).toContain("unique-search-term-abc")
  237. expect(titles).not.toContain("other-session-xyz")
  238. }),
  239. { git: true },
  240. )
  241. it.instance(
  242. "respects limit parameter",
  243. () =>
  244. Effect.gen(function* () {
  245. yield* withSession({ title: "session-1" })
  246. yield* withSession({ title: "session-2" })
  247. yield* withSession({ title: "session-3" })
  248. const sessions = yield* SessionNs.use.list({ limit: 2 })
  249. expect(sessions.length).toBe(2)
  250. }),
  251. { git: true },
  252. )
  253. it.instance(
  254. "includes metadata in listed sessions",
  255. () =>
  256. Effect.gen(function* () {
  257. const meta = { source: "sdk", trace: { id: "abc" } }
  258. const created = yield* withSession({ title: "meta-session", metadata: meta })
  259. const listed = (yield* SessionNs.Service.use((session) => session.list({ search: "meta-session" }))).find(
  260. (item) => item.id === created.id,
  261. )
  262. expect(listed?.metadata).toEqual(meta)
  263. }),
  264. { git: true },
  265. )
  266. })