session-create.test.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer, Stream } from "effect"
  3. import { AgentV2 } from "@opencode-ai/core/agent"
  4. import { eq } from "drizzle-orm"
  5. import { Database } from "@opencode-ai/core/database/database"
  6. import { EventV2 } from "@opencode-ai/core/event"
  7. import { EventTable } from "@opencode-ai/core/event/sql"
  8. import { Location } from "@opencode-ai/core/location"
  9. import { ModelV2 } from "@opencode-ai/core/model"
  10. import { ProjectV2 } from "@opencode-ai/core/project"
  11. import { ProviderV2 } from "@opencode-ai/core/provider"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { SessionV2 } from "@opencode-ai/core/session"
  14. import { SessionV1 } from "@opencode-ai/core/v1/session"
  15. import { Prompt } from "@opencode-ai/core/session/prompt"
  16. import { SessionProjector } from "@opencode-ai/core/session/projector"
  17. import { SessionExecution } from "@opencode-ai/core/session/execution"
  18. import { SessionInput } from "@opencode-ai/core/session/input"
  19. import { SessionTable } from "@opencode-ai/core/session/sql"
  20. import { SessionStore } from "@opencode-ai/core/session/store"
  21. import { WorkspaceV2 } from "@opencode-ai/core/workspace"
  22. import { testEffect } from "./lib/effect"
  23. const database = Database.layerFromPath(":memory:")
  24. const events = EventV2.layer.pipe(Layer.provide(database))
  25. const projects = Layer.succeed(
  26. ProjectV2.Service,
  27. ProjectV2.Service.of({
  28. resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
  29. directories: () => Effect.succeed([]),
  30. commit: () => Effect.void,
  31. }),
  32. )
  33. const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
  34. const store = SessionStore.layer.pipe(Layer.provide(database))
  35. const sessions = SessionV2.layer.pipe(
  36. Layer.provide(events),
  37. Layer.provide(database),
  38. Layer.provide(store),
  39. Layer.provide(projects),
  40. Layer.provide(SessionExecution.noopLayer),
  41. )
  42. const it = testEffect(
  43. Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions),
  44. )
  45. const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
  46. const id = SessionV2.ID.create()
  47. describe("SessionV2.create", () => {
  48. it.effect("derives stable namespaced external IDs", () =>
  49. Effect.sync(() => {
  50. const input = { namespace: "opencord.agent-thread", key: "thread-1" }
  51. expect(SessionV2.ID.fromExternal(input)).toBe(SessionV2.ID.fromExternal(input))
  52. expect(SessionV2.ID.fromExternal(input)).toMatch(/^ses_[a-f0-9]{64}$/)
  53. expect(SessionV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe(
  54. SessionV2.ID.fromExternal(input),
  55. )
  56. expect(SessionV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe(
  57. SessionV2.ID.fromExternal({ namespace: "a", key: "b:c" }),
  58. )
  59. }),
  60. )
  61. it.effect("creates a fresh projected session when the ID is omitted", () =>
  62. Effect.gen(function* () {
  63. const session = yield* SessionV2.Service
  64. const first = yield* session.create({ location })
  65. const second = yield* session.create({ location })
  66. expect(second.id).not.toBe(first.id)
  67. expect(yield* session.list()).toHaveLength(2)
  68. }),
  69. )
  70. it.effect("returns the original session when the ID is retried", () =>
  71. Effect.gen(function* () {
  72. const session = yield* SessionV2.Service
  73. const input = { id, location }
  74. const first = yield* session.create(input)
  75. const retried = yield* session.create(input)
  76. expect(retried).toEqual(first)
  77. expect(yield* session.list()).toEqual([first])
  78. }),
  79. )
  80. it.effect("stores supplied immutable create attributes", () =>
  81. Effect.gen(function* () {
  82. const session = yield* SessionV2.Service
  83. const workspaceID = WorkspaceV2.ID.make("wrk_test")
  84. const model = ModelV2.Ref.make({
  85. id: ModelV2.ID.make("sonnet"),
  86. providerID: ProviderV2.ID.anthropic,
  87. variant: ModelV2.VariantID.make("fast"),
  88. })
  89. expect(
  90. yield* session.create({
  91. location: Location.Ref.make({ directory: location.directory, workspaceID }),
  92. agent: AgentV2.ID.make("build"),
  93. model,
  94. }),
  95. ).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
  96. }),
  97. )
  98. it.effect("returns the existing Session when one ID is reused with different create arguments", () =>
  99. Effect.gen(function* () {
  100. const session = yield* SessionV2.Service
  101. const created = yield* session.create({ id, location })
  102. const changed = [
  103. { id, location: Location.Ref.make({ directory: AbsolutePath.make("/other") }) },
  104. { id, location, agent: AgentV2.ID.make("build") },
  105. {
  106. id,
  107. location,
  108. model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
  109. },
  110. ]
  111. for (const input of changed) {
  112. expect(yield* session.create(input)).toEqual(created)
  113. }
  114. expect(yield* session.list()).toHaveLength(1)
  115. }),
  116. )
  117. it.effect("returns one recorded session to concurrent exact retries", () =>
  118. Effect.gen(function* () {
  119. const session = yield* SessionV2.Service
  120. const input = { id, location }
  121. const created = yield* Effect.all([session.create(input), session.create(input)], { concurrency: "unbounded" })
  122. expect(created[1]).toEqual(created[0])
  123. expect(yield* session.list()).toEqual([created[0]])
  124. }),
  125. )
  126. it.effect("returns the current Session projection after updates", () =>
  127. Effect.gen(function* () {
  128. const session = yield* SessionV2.Service
  129. const { db } = yield* Database.Service
  130. const input = { id, location }
  131. const created = yield* session.create(input)
  132. yield* db.update(SessionTable).set({ agent: "build" }).where(eq(SessionTable.id, id)).run().pipe(Effect.orDie)
  133. expect(yield* session.create(input)).toMatchObject({ id: created.id, agent: "build" })
  134. }),
  135. )
  136. it.effect("returns the current Session projection after projected updates", () =>
  137. Effect.gen(function* () {
  138. const session = yield* SessionV2.Service
  139. const events = yield* EventV2.Service
  140. const input = { id, location }
  141. const created = yield* session.create(input)
  142. yield* events.publish(SessionV1.Event.Updated, {
  143. sessionID: id,
  144. info: SessionV1.SessionInfo.make({
  145. id,
  146. slug: "updated",
  147. version: "test",
  148. projectID: created.projectID,
  149. directory: created.location.directory,
  150. title: "updated",
  151. agent: "build",
  152. time: { created: 0, updated: 1 },
  153. }),
  154. })
  155. expect(yield* session.create(input)).toMatchObject({ id, agent: "build" })
  156. }),
  157. )
  158. it.effect("persists creation through the existing legacy created event", () =>
  159. Effect.gen(function* () {
  160. const session = yield* SessionV2.Service
  161. const { db } = yield* Database.Service
  162. const created = yield* session.create({ location })
  163. expect(
  164. yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
  165. ).toMatchObject([{ type: EventV2.versionedType(SessionV1.Event.Created.type, 1) }])
  166. }),
  167. )
  168. it.effect("persists caller-ID creation through the existing created event", () =>
  169. Effect.gen(function* () {
  170. const session = yield* SessionV2.Service
  171. const { db } = yield* Database.Service
  172. const created = yield* session.create({ id, location })
  173. expect(
  174. yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).get().pipe(Effect.orDie),
  175. ).toMatchObject({
  176. data: { sessionID: id },
  177. })
  178. }),
  179. )
  180. it.effect("omits legacy creation rows from the V2 Session event stream", () =>
  181. Effect.gen(function* () {
  182. const session = yield* SessionV2.Service
  183. const events = yield* EventV2.Service
  184. const { db } = yield* Database.Service
  185. const created = yield* session.create({ location })
  186. yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false })
  187. yield* SessionInput.promoteSteers(db, events, created.id)
  188. expect(
  189. Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
  190. ).toMatchObject([{ cursor: 1, event: { type: "session.next.prompted", data: { prompt: { text: "Hello" } } } }])
  191. }),
  192. )
  193. it.effect("does not mask unrelated created projector defects", () =>
  194. Effect.gen(function* () {
  195. const session = yield* SessionV2.Service
  196. const event = yield* EventV2.Service
  197. const defect = new Error("unrelated projector defect")
  198. yield* event.project(SessionV1.Event.Created, () => Effect.die(defect))
  199. expect(yield* session.create({ id, location }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
  200. }),
  201. )
  202. it.effect("reports unfinished Session operations as unavailable", () =>
  203. Effect.gen(function* () {
  204. const session = yield* SessionV2.Service
  205. const created = yield* session.create({ location })
  206. const unavailable = (
  207. effect: Effect.Effect<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>,
  208. ) =>
  209. effect.pipe(
  210. Effect.flip,
  211. Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")),
  212. )
  213. expect(yield* unavailable(session.move({ sessionID: created.id, location }))).toBe("move")
  214. expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
  215. expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
  216. expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")
  217. expect(
  218. yield* unavailable(
  219. session.switchModel({
  220. sessionID: created.id,
  221. model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
  222. }),
  223. ),
  224. ).toBe("switchModel")
  225. }),
  226. )
  227. })