session-history.test.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer, Schema } from "effect"
  3. import { Database } from "@opencode-ai/core/database/database"
  4. import { EventV2 } from "@opencode-ai/core/event"
  5. import { Location } from "@opencode-ai/core/location"
  6. import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
  7. import { ProjectV2 } from "@opencode-ai/core/project"
  8. import { ProjectTable } from "@opencode-ai/core/project/sql"
  9. import { AbsolutePath } from "@opencode-ai/core/schema"
  10. import { SessionV2 } from "@opencode-ai/core/session"
  11. import { SessionExecution } from "@opencode-ai/core/session/execution"
  12. import { SessionProjector } from "@opencode-ai/core/session/projector"
  13. import { SessionStore } from "@opencode-ai/core/session/store"
  14. import { SessionTable } from "@opencode-ai/core/session/sql"
  15. import { testEffect } from "./lib/effect"
  16. const projects = Layer.succeed(
  17. ProjectV2.Service,
  18. ProjectV2.Service.of({
  19. resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
  20. directories: () => Effect.succeed([]),
  21. commit: () => Effect.void,
  22. }),
  23. )
  24. const sessions = SessionV2.layer.pipe(
  25. Layer.provide(locationServiceMapLayer),
  26. Layer.provide(EventV2.defaultLayer),
  27. Layer.provide(Database.defaultLayer),
  28. Layer.provide(SessionStore.defaultLayer),
  29. Layer.provide(projects),
  30. Layer.provide(SessionExecution.noopLayer),
  31. )
  32. const it = testEffect(
  33. Layer.mergeAll(
  34. Database.defaultLayer,
  35. EventV2.defaultLayer,
  36. projects,
  37. SessionProjector.defaultLayer,
  38. SessionStore.defaultLayer,
  39. SessionExecution.noopLayer,
  40. sessions,
  41. ),
  42. )
  43. const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
  44. const GapEvent = EventV2.define({
  45. type: "test.session.history.gap",
  46. durable: { aggregate: "sessionID", version: 1 },
  47. schema: { sessionID: SessionV2.ID, value: Schema.String },
  48. })
  49. describe("SessionV2.history", () => {
  50. it.effect("returns an exhausted page for a migrated Session with no event sequence", () =>
  51. Effect.gen(function* () {
  52. const db = (yield* Database.Service).db
  53. const session = yield* SessionV2.Service
  54. const sessionID = SessionV2.ID.make("ses_empty_history")
  55. yield* db
  56. .insert(ProjectTable)
  57. .values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  58. .onConflictDoNothing()
  59. .run()
  60. yield* db
  61. .insert(SessionTable)
  62. .values({
  63. id: sessionID,
  64. project_id: ProjectV2.ID.global,
  65. slug: "empty-history",
  66. directory: "/project",
  67. title: "Empty history",
  68. version: "test",
  69. })
  70. .run()
  71. const first = yield* session.history({ sessionID, limit: 10 })
  72. expect(first).toEqual({ events: [], hasMore: false })
  73. }),
  74. )
  75. it.effect("treats after as an exclusive aggregate sequence", () =>
  76. Effect.gen(function* () {
  77. const session = yield* SessionV2.Service
  78. const created = yield* session.create({ location })
  79. yield* session.switchAgent({ sessionID: created.id, agent: "one" })
  80. yield* session.switchAgent({ sessionID: created.id, agent: "two" })
  81. const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 })
  82. expect(page.events.map((event) => event.durable?.seq)).toEqual([2])
  83. expect(page.hasMore).toBe(false)
  84. }),
  85. )
  86. it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () =>
  87. Effect.gen(function* () {
  88. const session = yield* SessionV2.Service
  89. const events = yield* EventV2.Service
  90. const created = yield* session.create({ location })
  91. yield* session.switchAgent({ sessionID: created.id, agent: "one" })
  92. yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
  93. yield* session.switchAgent({ sessionID: created.id, agent: "two" })
  94. yield* session.switchAgent({ sessionID: created.id, agent: "three" })
  95. const first = yield* session.history({ sessionID: created.id, limit: 2 })
  96. const after = first.events.at(-1)?.durable?.seq
  97. const second = yield* session.history({
  98. sessionID: created.id,
  99. after,
  100. limit: 2,
  101. })
  102. const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq)
  103. expect(first.hasMore).toBe(true)
  104. expect(second.hasMore).toBe(false)
  105. expect(sequence).toEqual([1, 3, 4])
  106. expect(new Set(sequence).size).toBe(sequence.length)
  107. }),
  108. )
  109. it.effect("includes events committed between pages", () =>
  110. Effect.gen(function* () {
  111. const session = yield* SessionV2.Service
  112. const created = yield* session.create({ location })
  113. yield* session.switchAgent({ sessionID: created.id, agent: "one" })
  114. yield* session.switchAgent({ sessionID: created.id, agent: "two" })
  115. const first = yield* session.history({ sessionID: created.id, limit: 1 })
  116. yield* session.switchAgent({ sessionID: created.id, agent: "later" })
  117. const second = yield* session.history({
  118. sessionID: created.id,
  119. after: first.events.at(-1)?.durable?.seq,
  120. limit: 10,
  121. })
  122. expect(first.hasMore).toBe(true)
  123. expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3])
  124. expect(second.hasMore).toBe(false)
  125. }),
  126. )
  127. it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () =>
  128. Effect.gen(function* () {
  129. const session = yield* SessionV2.Service
  130. const created = yield* session.create({ location })
  131. yield* session.switchAgent({ sessionID: created.id, agent: "one" })
  132. yield* session.switchAgent({ sessionID: created.id, agent: "two" })
  133. const exact = yield* session.history({ sessionID: created.id, limit: 2 })
  134. const oneMore = yield* session.history({ sessionID: created.id, limit: 1 })
  135. const exhausted = yield* session.history({
  136. sessionID: created.id,
  137. after: oneMore.events.at(-1)?.durable?.seq,
  138. limit: 1,
  139. })
  140. expect(exact.events).toHaveLength(2)
  141. expect(exact.hasMore).toBe(false)
  142. expect(oneMore.events).toHaveLength(1)
  143. expect(oneMore.hasMore).toBe(true)
  144. expect(exhausted.events).toHaveLength(1)
  145. expect(exhausted.hasMore).toBe(false)
  146. }),
  147. )
  148. it.effect("fails with NotFoundError for a missing Session", () =>
  149. Effect.gen(function* () {
  150. const session = yield* SessionV2.Service
  151. const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip)
  152. expect(error._tag).toBe("Session.NotFoundError")
  153. }),
  154. )
  155. })