session-history.test.ts 6.4 KB

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