session-create.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import { describe, expect } from "bun:test"
  2. import path from "path"
  3. import { Effect, Layer, Stream } from "effect"
  4. import { AgentV2 } from "@opencode-ai/core/agent"
  5. import { asc, eq } from "drizzle-orm"
  6. import { Database } from "@opencode-ai/core/database/database"
  7. import { EventV2 } from "@opencode-ai/core/event"
  8. import { EventTable } from "@opencode-ai/core/event/sql"
  9. import { Location } from "@opencode-ai/core/location"
  10. import { ModelV2 } from "@opencode-ai/core/model"
  11. import { ProjectV2 } from "@opencode-ai/core/project"
  12. import { ProjectTable } from "@opencode-ai/core/project/sql"
  13. import { ProviderV2 } from "@opencode-ai/core/provider"
  14. import { AbsolutePath } from "@opencode-ai/core/schema"
  15. import { SessionV2 } from "@opencode-ai/core/session"
  16. import { SessionV1 } from "@opencode-ai/core/v1/session"
  17. import { Prompt } from "@opencode-ai/core/session/prompt"
  18. import { SessionProjector } from "@opencode-ai/core/session/projector"
  19. import { SessionExecution } from "@opencode-ai/core/session/execution"
  20. import { SessionInput } from "@opencode-ai/core/session/input"
  21. import { SessionEvent } from "@opencode-ai/core/session/event"
  22. import { SessionTable } from "@opencode-ai/core/session/sql"
  23. import { SessionStore } from "@opencode-ai/core/session/store"
  24. import { WorkspaceV2 } from "@opencode-ai/core/workspace"
  25. import { testEffect } from "./lib/effect"
  26. import { tmpdir } from "./fixture/tmpdir"
  27. const database = Database.layerFromPath(":memory:")
  28. const events = EventV2.layer.pipe(Layer.provide(database))
  29. const projects = Layer.succeed(
  30. ProjectV2.Service,
  31. ProjectV2.Service.of({
  32. resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
  33. directories: () => Effect.succeed([]),
  34. commit: () => Effect.void,
  35. }),
  36. )
  37. const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
  38. const store = SessionStore.layer.pipe(Layer.provide(database))
  39. const sessions = SessionV2.layer.pipe(
  40. Layer.provide(events),
  41. Layer.provide(database),
  42. Layer.provide(store),
  43. Layer.provide(projects),
  44. Layer.provide(SessionExecution.noopLayer),
  45. )
  46. const it = testEffect(
  47. Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions),
  48. )
  49. const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
  50. const id = SessionV2.ID.create()
  51. describe("SessionV2.create", () => {
  52. it.effect("derives stable namespaced external IDs", () =>
  53. Effect.sync(() => {
  54. const input = { namespace: "opencord.agent-thread", key: "thread-1" }
  55. expect(SessionV2.ID.fromExternal(input)).toBe(SessionV2.ID.fromExternal(input))
  56. expect(SessionV2.ID.fromExternal(input)).toMatch(/^ses_[a-f0-9]{64}$/)
  57. expect(SessionV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe(
  58. SessionV2.ID.fromExternal(input),
  59. )
  60. expect(SessionV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe(
  61. SessionV2.ID.fromExternal({ namespace: "a", key: "b:c" }),
  62. )
  63. }),
  64. )
  65. it.effect("creates a fresh projected session when the ID is omitted", () =>
  66. Effect.gen(function* () {
  67. const session = yield* SessionV2.Service
  68. const first = yield* session.create({ location })
  69. const second = yield* session.create({ location })
  70. expect(second.id).not.toBe(first.id)
  71. expect(yield* session.list()).toHaveLength(2)
  72. }),
  73. )
  74. it.effect("returns the original session when the ID is retried", () =>
  75. Effect.gen(function* () {
  76. const session = yield* SessionV2.Service
  77. const input = { id, location }
  78. const first = yield* session.create(input)
  79. const retried = yield* session.create(input)
  80. expect(retried).toEqual(first)
  81. expect(yield* session.list()).toEqual([first])
  82. }),
  83. )
  84. it.effect("stores supplied immutable create attributes", () =>
  85. Effect.gen(function* () {
  86. const session = yield* SessionV2.Service
  87. const workspaceID = WorkspaceV2.ID.make("wrk_test")
  88. const model = ModelV2.Ref.make({
  89. id: ModelV2.ID.make("sonnet"),
  90. providerID: ProviderV2.ID.anthropic,
  91. variant: ModelV2.VariantID.make("fast"),
  92. })
  93. expect(
  94. yield* session.create({
  95. location: Location.Ref.make({ directory: location.directory, workspaceID }),
  96. agent: AgentV2.ID.make("build"),
  97. model,
  98. }),
  99. ).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
  100. }),
  101. )
  102. it.effect("returns the existing Session when one ID is reused with different create arguments", () =>
  103. Effect.gen(function* () {
  104. const session = yield* SessionV2.Service
  105. const created = yield* session.create({ id, location })
  106. const changed = [
  107. { id, location: Location.Ref.make({ directory: AbsolutePath.make("/other") }) },
  108. { id, location, agent: AgentV2.ID.make("build") },
  109. {
  110. id,
  111. location,
  112. model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
  113. },
  114. ]
  115. for (const input of changed) {
  116. expect(yield* session.create(input)).toEqual(created)
  117. }
  118. expect(yield* session.list()).toHaveLength(1)
  119. }),
  120. )
  121. it.effect("returns one recorded session to concurrent exact retries", () =>
  122. Effect.gen(function* () {
  123. const session = yield* SessionV2.Service
  124. const input = { id, location }
  125. const created = yield* Effect.all([session.create(input), session.create(input)], { concurrency: "unbounded" })
  126. expect(created[1]).toEqual(created[0])
  127. expect(yield* session.list()).toEqual([created[0]])
  128. }),
  129. )
  130. it.effect("returns the current Session projection after updates", () =>
  131. Effect.gen(function* () {
  132. const session = yield* SessionV2.Service
  133. const { db } = yield* Database.Service
  134. const input = { id, location }
  135. const created = yield* session.create(input)
  136. yield* db.update(SessionTable).set({ agent: "build" }).where(eq(SessionTable.id, id)).run().pipe(Effect.orDie)
  137. expect(yield* session.create(input)).toMatchObject({ id: created.id, agent: "build" })
  138. }),
  139. )
  140. it.effect("returns the current Session projection after projected updates", () =>
  141. Effect.gen(function* () {
  142. const session = yield* SessionV2.Service
  143. const events = yield* EventV2.Service
  144. const input = { id, location }
  145. const created = yield* session.create(input)
  146. yield* events.publish(SessionV1.Event.Updated, {
  147. sessionID: id,
  148. info: SessionV1.SessionInfo.make({
  149. id,
  150. slug: "updated",
  151. version: "test",
  152. projectID: created.projectID,
  153. directory: created.location.directory,
  154. title: "updated",
  155. agent: "build",
  156. time: { created: 0, updated: 1 },
  157. }),
  158. })
  159. expect(yield* session.create(input)).toMatchObject({ id, agent: "build" })
  160. }),
  161. )
  162. it.effect("persists creation through the existing legacy created event", () =>
  163. Effect.gen(function* () {
  164. const session = yield* SessionV2.Service
  165. const { db } = yield* Database.Service
  166. const created = yield* session.create({ location })
  167. expect(
  168. yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
  169. ).toMatchObject([{ type: EventV2.versionedType(SessionV1.Event.Created.type, 1) }])
  170. }),
  171. )
  172. it.effect("persists caller-ID creation through the existing created event", () =>
  173. Effect.gen(function* () {
  174. const session = yield* SessionV2.Service
  175. const { db } = yield* Database.Service
  176. const created = yield* session.create({ id, location })
  177. expect(
  178. yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).get().pipe(Effect.orDie),
  179. ).toMatchObject({
  180. data: { sessionID: id },
  181. })
  182. }),
  183. )
  184. it.effect("omits legacy creation rows from the V2 Session event stream", () =>
  185. Effect.gen(function* () {
  186. const session = yield* SessionV2.Service
  187. const events = yield* EventV2.Service
  188. const { db } = yield* Database.Service
  189. const created = yield* session.create({ location })
  190. yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false })
  191. yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
  192. expect(
  193. Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
  194. ).toMatchObject([
  195. { cursor: 1, event: { type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } } },
  196. { cursor: 2, event: { type: "session.next.prompt.promoted" } },
  197. ])
  198. }),
  199. )
  200. it.effect("replays one prompt lifecycle into a fresh target database", () =>
  201. Effect.gen(function* () {
  202. const session = yield* SessionV2.Service
  203. const sourceEvents = yield* EventV2.Service
  204. const sourceDb = (yield* Database.Service).db
  205. const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location })
  206. const admitted = yield* session.prompt({
  207. sessionID: created.id,
  208. prompt: new Prompt({ text: "Replay lifecycle" }),
  209. resume: false,
  210. })
  211. yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER)
  212. const serialized = (yield* sourceDb
  213. .select()
  214. .from(EventTable)
  215. .where(eq(EventTable.aggregate_id, created.id))
  216. .orderBy(asc(EventTable.seq))
  217. .all()
  218. .pipe(Effect.orDie)).map((event) => ({
  219. id: event.id,
  220. aggregateID: event.aggregate_id,
  221. seq: event.seq,
  222. type: event.type,
  223. data: event.data,
  224. }))
  225. const tmp = yield* Effect.acquireRelease(
  226. Effect.promise(() => tmpdir()),
  227. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  228. )
  229. const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite"))
  230. const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase))
  231. const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase))
  232. const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase))
  233. yield* Effect.gen(function* () {
  234. const db = (yield* Database.Service).db
  235. const events = yield* EventV2.Service
  236. const store = yield* SessionStore.Service
  237. yield* db
  238. .insert(ProjectTable)
  239. .values({ id: ProjectV2.ID.global, worktree: location.directory, sandboxes: [] })
  240. .run()
  241. .pipe(Effect.orDie)
  242. expect(yield* store.get(created.id)).toBeUndefined()
  243. expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id)
  244. expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
  245. id: admitted.id,
  246. sessionID: created.id,
  247. prompt: { text: "Replay lifecycle" },
  248. delivery: "steer",
  249. admittedSeq: 1,
  250. })
  251. expect(yield* store.context(created.id)).toEqual([])
  252. expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id)
  253. expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
  254. id: admitted.id,
  255. sessionID: created.id,
  256. prompt: { text: "Replay lifecycle" },
  257. delivery: "steer",
  258. admittedSeq: 1,
  259. promotedSeq: 2,
  260. })
  261. expect(yield* store.context(created.id)).toMatchObject([
  262. { id: admitted.id, type: "user", text: "Replay lifecycle" },
  263. ])
  264. expect(
  265. (yield* db
  266. .select()
  267. .from(EventTable)
  268. .where(eq(EventTable.aggregate_id, created.id))
  269. .orderBy(asc(EventTable.seq))
  270. .all()
  271. .pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
  272. ).toEqual([
  273. [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
  274. [1, EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1)],
  275. [2, EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1)],
  276. ])
  277. }).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore))))
  278. }),
  279. )
  280. it.effect("does not mask unrelated created projector defects", () =>
  281. Effect.gen(function* () {
  282. const session = yield* SessionV2.Service
  283. const event = yield* EventV2.Service
  284. const defect = new Error("unrelated projector defect")
  285. yield* event.project(SessionV1.Event.Created, () => Effect.die(defect))
  286. expect(yield* session.create({ id, location }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
  287. }),
  288. )
  289. it.effect("reports unfinished Session operations as unavailable", () =>
  290. Effect.gen(function* () {
  291. const session = yield* SessionV2.Service
  292. const created = yield* session.create({ location })
  293. const unavailable = (
  294. effect: Effect.Effect<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>,
  295. ) =>
  296. effect.pipe(
  297. Effect.flip,
  298. Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")),
  299. )
  300. expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
  301. expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
  302. expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")
  303. }),
  304. )
  305. it.effect("switches the selected model through the durable Session event", () =>
  306. Effect.gen(function* () {
  307. const session = yield* SessionV2.Service
  308. const created = yield* session.create({ location })
  309. const model = ModelV2.Ref.make({
  310. id: ModelV2.ID.make("sonnet"),
  311. providerID: ProviderV2.ID.anthropic,
  312. variant: ModelV2.VariantID.make("high"),
  313. })
  314. yield* session.switchModel({ sessionID: created.id, model })
  315. expect(yield* session.get(created.id)).toMatchObject({ model })
  316. expect(
  317. Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
  318. ).toMatchObject([{ event: { type: "session.next.model.switched", data: { model } } }])
  319. }),
  320. )
  321. it.effect("persists repeated switches as distinct durable Session events", () =>
  322. Effect.gen(function* () {
  323. const session = yield* SessionV2.Service
  324. const created = yield* session.create({ location })
  325. const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
  326. yield* session.switchModel({ sessionID: created.id, model })
  327. yield* session.switchModel({ sessionID: created.id, model })
  328. const { db } = yield* Database.Service
  329. expect(
  330. yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
  331. ).toHaveLength(3)
  332. expect(yield* session.get(created.id)).toMatchObject({ model })
  333. }),
  334. )
  335. it.effect("rejects a model switch for a missing Session", () =>
  336. Effect.gen(function* () {
  337. const session = yield* SessionV2.Service
  338. const missing = SessionV2.ID.make("ses_missing_model_switch")
  339. expect(
  340. yield* session
  341. .switchModel({
  342. sessionID: missing,
  343. model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
  344. })
  345. .pipe(
  346. Effect.flip,
  347. Effect.map((error) => error._tag),
  348. ),
  349. ).toBe("Session.NotFoundError")
  350. }),
  351. )
  352. })