session-create.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. import { describe, expect } from "bun:test"
  2. import path from "path"
  3. import { DateTime, 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 { locationServiceMapLayer } from "@opencode-ai/core/location-services"
  17. import { SessionV1 } from "@opencode-ai/core/v1/session"
  18. import { Prompt } from "@opencode-ai/core/session/prompt"
  19. import { SessionProjector } from "@opencode-ai/core/session/projector"
  20. import { SessionExecution } from "@opencode-ai/core/session/execution"
  21. import { SessionInput } from "@opencode-ai/core/session/input"
  22. import { SessionEvent } from "@opencode-ai/core/session/event"
  23. import { SessionMessage } from "@opencode-ai/core/session/message"
  24. import { SessionTable } from "@opencode-ai/core/session/sql"
  25. import { SessionStore } from "@opencode-ai/core/session/store"
  26. import { WorkspaceV2 } from "@opencode-ai/core/workspace"
  27. import { testEffect } from "./lib/effect"
  28. import { tmpdir } from "./fixture/tmpdir"
  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 sessions = SessionV2.layer.pipe(
  38. Layer.provide(locationServiceMapLayer),
  39. Layer.provide(EventV2.defaultLayer),
  40. Layer.provide(Database.defaultLayer),
  41. Layer.provide(SessionStore.defaultLayer),
  42. Layer.provide(projects),
  43. Layer.provide(SessionExecution.noopLayer),
  44. )
  45. const it = testEffect(
  46. Layer.mergeAll(
  47. Database.defaultLayer,
  48. EventV2.defaultLayer,
  49. projects,
  50. SessionProjector.defaultLayer,
  51. SessionStore.defaultLayer,
  52. SessionExecution.noopLayer,
  53. sessions,
  54. ),
  55. )
  56. const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
  57. const id = SessionV2.ID.create()
  58. const assertCreateInputTypes = (session: SessionV2.Interface) => {
  59. // @ts-expect-error location or parentID is required.
  60. session.create({})
  61. // @ts-expect-error child sessions inherit their parent's location.
  62. session.create({ parentID: SessionV2.ID.create(), location })
  63. }
  64. void assertCreateInputTypes
  65. describe("SessionV2.create", () => {
  66. it.effect("creates a fresh projected session when the ID is omitted", () =>
  67. Effect.gen(function* () {
  68. const session = yield* SessionV2.Service
  69. const first = yield* session.create({ location })
  70. const second = yield* session.create({ location })
  71. expect(second.id).not.toBe(first.id)
  72. expect(yield* session.list()).toHaveLength(2)
  73. }),
  74. )
  75. it.effect("returns the original session when the ID is retried", () =>
  76. Effect.gen(function* () {
  77. const session = yield* SessionV2.Service
  78. const input = { id, location }
  79. const first = yield* session.create(input)
  80. const retried = yield* session.create(input)
  81. expect(retried).toEqual(first)
  82. expect(yield* session.list()).toEqual([first])
  83. }),
  84. )
  85. it.effect("stores supplied immutable create attributes", () =>
  86. Effect.gen(function* () {
  87. const session = yield* SessionV2.Service
  88. const workspaceID = WorkspaceV2.ID.make("wrk_test")
  89. const model = ModelV2.Ref.make({
  90. id: ModelV2.ID.make("sonnet"),
  91. providerID: ProviderV2.ID.anthropic,
  92. variant: ModelV2.VariantID.make("fast"),
  93. })
  94. expect(
  95. yield* session.create({
  96. location: Location.Ref.make({ directory: location.directory, workspaceID }),
  97. agent: AgentV2.ID.make("build"),
  98. model,
  99. }),
  100. ).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
  101. }),
  102. )
  103. it.effect("inherits location from an existing parent when omitted", () =>
  104. Effect.gen(function* () {
  105. const session = yield* SessionV2.Service
  106. const parent = yield* session.create({ location })
  107. const child = yield* session.create({ parentID: parent.id, title: "child" })
  108. expect(child).toMatchObject({ parentID: parent.id, location })
  109. }),
  110. )
  111. it.effect("rejects child creation when the parent does not exist", () =>
  112. Effect.gen(function* () {
  113. const session = yield* SessionV2.Service
  114. const missing = SessionV2.ID.create()
  115. expect(yield* Effect.flip(session.create({ parentID: missing, title: "child" }))).toEqual(
  116. new SessionV2.NotFoundError({ sessionID: missing }),
  117. )
  118. }),
  119. )
  120. it.effect("forks a session by replaying a durable fork event into copied projected rows", () =>
  121. Effect.gen(function* () {
  122. const session = yield* SessionV2.Service
  123. const events = yield* EventV2.Service
  124. const { db } = yield* Database.Service
  125. const parent = yield* session.create({ location, title: "Parent" })
  126. const admitted = yield* session.prompt({
  127. sessionID: parent.id,
  128. prompt: Prompt.make({ text: "First" }),
  129. resume: false,
  130. })
  131. yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
  132. yield* events.publish(SessionEvent.Synthetic, {
  133. sessionID: parent.id,
  134. messageID: SessionMessage.ID.create(),
  135. timestamp: yield* DateTime.now,
  136. text: "parent note",
  137. })
  138. const forked = yield* session.fork({ sessionID: parent.id })
  139. const parentContext = yield* session.context(parent.id)
  140. const forkContext = yield* session.context(forked.id)
  141. const history = yield* session.history({ sessionID: forked.id, limit: 10 })
  142. expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" })
  143. expect(forkContext).toMatchObject([
  144. { type: "user", text: "First" },
  145. { type: "synthetic", text: "parent note", sessionID: forked.id },
  146. ])
  147. expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
  148. expect(history.events).toHaveLength(1)
  149. expect(history.events[0]).toMatchObject({
  150. type: "session.next.forked",
  151. durable: { seq: 0 },
  152. data: { sessionID: forked.id, parentID: parent.id },
  153. })
  154. expect(yield* SessionInput.find(db, forkContext[0]!.id)).toMatchObject({
  155. sessionID: forked.id,
  156. prompt: { text: "First" },
  157. promotedSeq: 2,
  158. })
  159. yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false })
  160. yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
  161. yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false })
  162. yield* SessionInput.promoteSteers(db, events, forked.id, Number.MAX_SAFE_INTEGER)
  163. expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
  164. expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
  165. expect((yield* session.context(forked.id)).at(-1)).toMatchObject({ text: "Child continues" })
  166. expect((yield* session.history({ sessionID: forked.id, limit: 10 })).events.map((event) => event.durable?.seq)).toEqual([
  167. 0,
  168. 4,
  169. 5,
  170. ])
  171. expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
  172. }),
  173. )
  174. it.effect("forks before the selected boundary message", () =>
  175. Effect.gen(function* () {
  176. const session = yield* SessionV2.Service
  177. const events = yield* EventV2.Service
  178. const { db } = yield* Database.Service
  179. const parent = yield* session.create({ location })
  180. const first = yield* session.prompt({
  181. sessionID: parent.id,
  182. prompt: Prompt.make({ text: "First" }),
  183. resume: false,
  184. })
  185. yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
  186. const second = yield* session.prompt({
  187. sessionID: parent.id,
  188. prompt: Prompt.make({ text: "Second" }),
  189. resume: false,
  190. })
  191. yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
  192. const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
  193. const context = yield* session.context(forked.id)
  194. const history = yield* session.history({ sessionID: forked.id, limit: 10 })
  195. expect(context).toMatchObject([{ text: "First" }])
  196. expect(context[0]?.id).not.toBe(first.id)
  197. expect(history.events[0]).toMatchObject({ data: { messageID: second.id } })
  198. }),
  199. )
  200. it.effect("returns the existing Session when one ID is reused with different create arguments", () =>
  201. Effect.gen(function* () {
  202. const session = yield* SessionV2.Service
  203. const created = yield* session.create({ id, location })
  204. const changed = [
  205. { id, location: Location.Ref.make({ directory: AbsolutePath.make("/other") }) },
  206. { id, location, agent: AgentV2.ID.make("build") },
  207. {
  208. id,
  209. location,
  210. model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
  211. },
  212. ]
  213. for (const input of changed) {
  214. expect(yield* session.create(input)).toEqual(created)
  215. }
  216. expect(yield* session.list()).toHaveLength(1)
  217. }),
  218. )
  219. it.effect("returns one recorded session to concurrent exact retries", () =>
  220. Effect.gen(function* () {
  221. const session = yield* SessionV2.Service
  222. const input = { id, location }
  223. const created = yield* Effect.all([session.create(input), session.create(input)], { concurrency: "unbounded" })
  224. expect(created[1]).toEqual(created[0])
  225. expect(yield* session.list()).toEqual([created[0]])
  226. }),
  227. )
  228. it.effect("returns the current Session projection after updates", () =>
  229. Effect.gen(function* () {
  230. const session = yield* SessionV2.Service
  231. const { db } = yield* Database.Service
  232. const input = { id, location }
  233. const created = yield* session.create(input)
  234. yield* db.update(SessionTable).set({ agent: "build" }).where(eq(SessionTable.id, id)).run().pipe(Effect.orDie)
  235. expect(yield* session.create(input)).toMatchObject({ id: created.id, agent: "build" })
  236. }),
  237. )
  238. it.effect("returns the current Session projection after projected updates", () =>
  239. Effect.gen(function* () {
  240. const session = yield* SessionV2.Service
  241. const events = yield* EventV2.Service
  242. const input = { id, location }
  243. const created = yield* session.create(input)
  244. yield* events.publish(SessionV1.Event.Updated, {
  245. sessionID: id,
  246. info: SessionV1.SessionInfo.make({
  247. id,
  248. slug: "updated",
  249. version: "test",
  250. projectID: created.projectID,
  251. directory: created.location.directory,
  252. title: "updated",
  253. agent: "build",
  254. time: { created: 0, updated: 1 },
  255. }),
  256. })
  257. expect(yield* session.create(input)).toMatchObject({ id, agent: "build" })
  258. }),
  259. )
  260. it.effect("persists creation through the existing legacy created event", () =>
  261. Effect.gen(function* () {
  262. const session = yield* SessionV2.Service
  263. const { db } = yield* Database.Service
  264. const created = yield* session.create({ location })
  265. expect(
  266. yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
  267. ).toMatchObject([{ type: EventV2.versionedType(SessionV1.Event.Created.type, 1) }])
  268. }),
  269. )
  270. it.effect("persists caller-ID creation through the existing created event", () =>
  271. Effect.gen(function* () {
  272. const session = yield* SessionV2.Service
  273. const { db } = yield* Database.Service
  274. const created = yield* session.create({ id, location })
  275. expect(
  276. yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).get().pipe(Effect.orDie),
  277. ).toMatchObject({
  278. data: { sessionID: id },
  279. })
  280. }),
  281. )
  282. it.effect("omits legacy creation rows from the V2 Session event stream", () =>
  283. Effect.gen(function* () {
  284. const session = yield* SessionV2.Service
  285. const events = yield* EventV2.Service
  286. const { db } = yield* Database.Service
  287. const created = yield* session.create({ location })
  288. yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false })
  289. yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
  290. expect(
  291. Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
  292. ).toMatchObject([
  293. { durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
  294. { durable: { seq: 2 }, type: "session.next.prompted" },
  295. ])
  296. }),
  297. )
  298. it.effect("replays one prompt lifecycle into a fresh target database", () =>
  299. Effect.gen(function* () {
  300. const session = yield* SessionV2.Service
  301. const sourceEvents = yield* EventV2.Service
  302. const sourceDb = (yield* Database.Service).db
  303. const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location })
  304. const admitted = yield* session.prompt({
  305. sessionID: created.id,
  306. prompt: Prompt.make({ text: "Replay lifecycle" }),
  307. resume: false,
  308. })
  309. yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER)
  310. const serialized = (yield* sourceDb
  311. .select()
  312. .from(EventTable)
  313. .where(eq(EventTable.aggregate_id, created.id))
  314. .orderBy(asc(EventTable.seq))
  315. .all()
  316. .pipe(Effect.orDie)).map((event) => ({
  317. id: event.id,
  318. aggregateID: event.aggregate_id,
  319. seq: event.seq,
  320. type: event.type,
  321. data: event.data,
  322. }))
  323. const tmp = yield* Effect.acquireRelease(
  324. Effect.promise(() => tmpdir()),
  325. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  326. )
  327. const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite"))
  328. const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase))
  329. const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase))
  330. const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase))
  331. yield* Effect.gen(function* () {
  332. const db = (yield* Database.Service).db
  333. const events = yield* EventV2.Service
  334. const store = yield* SessionStore.Service
  335. yield* db
  336. .insert(ProjectTable)
  337. .values({ id: ProjectV2.ID.global, worktree: location.directory, sandboxes: [] })
  338. .run()
  339. .pipe(Effect.orDie)
  340. expect(yield* store.get(created.id)).toBeUndefined()
  341. expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id)
  342. expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
  343. id: admitted.id,
  344. sessionID: created.id,
  345. prompt: { text: "Replay lifecycle" },
  346. delivery: "steer",
  347. admittedSeq: 1,
  348. })
  349. expect(yield* store.context(created.id)).toEqual([])
  350. expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id)
  351. expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
  352. id: admitted.id,
  353. sessionID: created.id,
  354. prompt: { text: "Replay lifecycle" },
  355. delivery: "steer",
  356. admittedSeq: 1,
  357. promotedSeq: 2,
  358. })
  359. expect(yield* store.context(created.id)).toMatchObject([
  360. { id: admitted.id, type: "user", text: "Replay lifecycle" },
  361. ])
  362. expect(
  363. (yield* db
  364. .select()
  365. .from(EventTable)
  366. .where(eq(EventTable.aggregate_id, created.id))
  367. .orderBy(asc(EventTable.seq))
  368. .all()
  369. .pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
  370. ).toEqual([
  371. [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
  372. [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)],
  373. [2, EventV2.versionedType(SessionEvent.Prompted.type, 1)],
  374. ])
  375. }).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore))))
  376. }),
  377. )
  378. it.effect("does not mask unrelated created projector defects", () =>
  379. Effect.gen(function* () {
  380. const session = yield* SessionV2.Service
  381. const event = yield* EventV2.Service
  382. const defect = new Error("unrelated projector defect")
  383. yield* event.project(SessionV1.Event.Created, () => Effect.die(defect))
  384. expect(yield* session.create({ id, location }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
  385. }),
  386. )
  387. it.effect("reports unfinished Session operations as unavailable", () =>
  388. Effect.gen(function* () {
  389. const session = yield* SessionV2.Service
  390. const created = yield* session.create({ location })
  391. const unavailable = (
  392. effect: Effect.Effect<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>,
  393. ) =>
  394. effect.pipe(
  395. Effect.flip,
  396. Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")),
  397. )
  398. expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
  399. }),
  400. )
  401. it.effect("switches the selected agent through the durable Session event", () =>
  402. Effect.gen(function* () {
  403. const session = yield* SessionV2.Service
  404. const created = yield* session.create({ location })
  405. yield* session.switchAgent({ sessionID: created.id, agent: "plan" })
  406. expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
  407. expect(
  408. Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
  409. ).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }])
  410. }),
  411. )
  412. it.effect("rejects an agent switch for a missing Session", () =>
  413. Effect.gen(function* () {
  414. const session = yield* SessionV2.Service
  415. const missing = SessionV2.ID.make("ses_missing_agent_switch")
  416. expect(
  417. yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe(
  418. Effect.flip,
  419. Effect.map((error) => error._tag),
  420. ),
  421. ).toBe("Session.NotFoundError")
  422. }),
  423. )
  424. it.effect("switches the selected model through the durable Session event", () =>
  425. Effect.gen(function* () {
  426. const session = yield* SessionV2.Service
  427. const created = yield* session.create({ location })
  428. const model = ModelV2.Ref.make({
  429. id: ModelV2.ID.make("sonnet"),
  430. providerID: ProviderV2.ID.anthropic,
  431. variant: ModelV2.VariantID.make("high"),
  432. })
  433. yield* session.switchModel({ sessionID: created.id, model })
  434. expect(yield* session.get(created.id)).toMatchObject({ model })
  435. expect(
  436. Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
  437. ).toMatchObject([{ type: "session.next.model.switched", data: { model } }])
  438. }),
  439. )
  440. it.effect("ignores a model switch when the selected model is unchanged", () =>
  441. Effect.gen(function* () {
  442. const session = yield* SessionV2.Service
  443. const created = yield* session.create({ location })
  444. const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
  445. yield* session.switchModel({ sessionID: created.id, model })
  446. yield* session.switchModel({ sessionID: created.id, model })
  447. const { db } = yield* Database.Service
  448. expect(
  449. yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
  450. ).toHaveLength(2)
  451. expect(yield* session.get(created.id)).toMatchObject({ model })
  452. }),
  453. )
  454. it.effect("treats an omitted variant as the default variant", () =>
  455. Effect.gen(function* () {
  456. const session = yield* SessionV2.Service
  457. const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
  458. const created = yield* session.create({ location, model })
  459. yield* session.switchModel({
  460. sessionID: created.id,
  461. model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }),
  462. })
  463. const { db } = yield* Database.Service
  464. expect(
  465. yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
  466. ).toHaveLength(1)
  467. }),
  468. )
  469. it.effect("rejects a model switch for a missing Session", () =>
  470. Effect.gen(function* () {
  471. const session = yield* SessionV2.Service
  472. const missing = SessionV2.ID.make("ses_missing_model_switch")
  473. expect(
  474. yield* session
  475. .switchModel({
  476. sessionID: missing,
  477. model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
  478. })
  479. .pipe(
  480. Effect.flip,
  481. Effect.map((error) => error._tag),
  482. ),
  483. ).toBe("Session.NotFoundError")
  484. }),
  485. )
  486. })