session-list.test.ts 11 KB

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