session-prompt.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. import { describe, expect } from "bun:test"
  2. import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
  3. import { eq } from "drizzle-orm"
  4. import { Database } from "@opencode-ai/core/database/database"
  5. import { EventV2 } from "@opencode-ai/core/event"
  6. import { EventTable } from "@opencode-ai/core/event/sql"
  7. import { SessionEvent } from "@opencode-ai/core/session/event"
  8. import { Project } 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 { LocationServiceMap } from "@opencode-ai/core/location-layer"
  13. import { Prompt } from "@opencode-ai/core/session/prompt"
  14. import { SessionMessage } from "@opencode-ai/core/session/message"
  15. import { SessionProjector } from "@opencode-ai/core/session/projector"
  16. import { SessionExecution } from "@opencode-ai/core/session/execution"
  17. import { SessionInput } from "@opencode-ai/core/session/input"
  18. import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
  19. import { SessionStore } from "@opencode-ai/core/session/store"
  20. import { testEffect } from "./lib/effect"
  21. const executionCalls: SessionV2.ID[] = []
  22. const interruptCalls: SessionV2.ID[] = []
  23. const wakeCalls: SessionV2.ID[] = []
  24. const activeSessions = new Set<SessionV2.ID>()
  25. const execution = Layer.succeed(
  26. SessionExecution.Service,
  27. SessionExecution.Service.of({
  28. active: Effect.sync(() => new Set(activeSessions)),
  29. resume: (sessionID) =>
  30. Effect.sync(() => {
  31. executionCalls.push(sessionID)
  32. }),
  33. interrupt: (sessionID) =>
  34. Effect.sync(() => {
  35. interruptCalls.push(sessionID)
  36. }),
  37. wake: (sessionID) =>
  38. Effect.sync(() => {
  39. wakeCalls.push(sessionID)
  40. }),
  41. }),
  42. )
  43. const sessions = SessionV2.layer.pipe(
  44. Layer.provide(LocationServiceMap.layer),
  45. Layer.provide(EventV2.defaultLayer),
  46. Layer.provide(Database.defaultLayer),
  47. Layer.provide(SessionStore.defaultLayer),
  48. Layer.provide(Project.defaultLayer),
  49. Layer.provide(execution),
  50. )
  51. const it = testEffect(
  52. Layer.mergeAll(
  53. Database.defaultLayer,
  54. EventV2.defaultLayer,
  55. SessionProjector.defaultLayer,
  56. SessionStore.defaultLayer,
  57. execution,
  58. sessions,
  59. ),
  60. )
  61. const sessionID = SessionV2.ID.make("ses_prompt_test")
  62. const messageID = SessionMessage.ID.create()
  63. const setup = Effect.gen(function* () {
  64. const { db } = yield* Database.Service
  65. yield* db
  66. .insert(ProjectTable)
  67. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  68. .onConflictDoNothing()
  69. .run()
  70. .pipe(Effect.orDie)
  71. yield* db
  72. .insert(SessionTable)
  73. .values({
  74. id: sessionID,
  75. project_id: Project.ID.global,
  76. slug: "test",
  77. directory: "/project",
  78. title: "test",
  79. version: "test",
  80. })
  81. .onConflictDoNothing()
  82. .run()
  83. .pipe(Effect.orDie)
  84. })
  85. const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInput.find(db, id))
  86. const admittedCount = Database.Service.use(({ db }) =>
  87. db
  88. .select()
  89. .from(SessionInputTable)
  90. .all()
  91. .pipe(
  92. Effect.orDie,
  93. Effect.map((rows) => rows.length),
  94. ),
  95. )
  96. const eventCount = (type: string) =>
  97. Database.Service.use(({ db }) =>
  98. db
  99. .select()
  100. .from(EventTable)
  101. .where(eq(EventTable.type, type))
  102. .all()
  103. .pipe(
  104. Effect.orDie,
  105. Effect.map((rows) => rows.length),
  106. ),
  107. )
  108. describe("SessionV2.prompt", () => {
  109. it.effect("exposes the execution registry", () =>
  110. Effect.gen(function* () {
  111. activeSessions.add(sessionID)
  112. expect(Array.from(yield* (yield* SessionV2.Service).active)).toEqual([sessionID])
  113. }).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
  114. )
  115. it.effect("delegates execution continuation through SessionExecution", () =>
  116. Effect.gen(function* () {
  117. yield* setup
  118. const session = yield* SessionV2.Service
  119. executionCalls.length = 0
  120. wakeCalls.length = 0
  121. yield* session.resume(sessionID)
  122. expect(executionCalls).toEqual([sessionID])
  123. expect(wakeCalls).toEqual([])
  124. }),
  125. )
  126. it.effect("delegates process-local interruption through SessionExecution", () =>
  127. Effect.gen(function* () {
  128. yield* setup
  129. const session = yield* SessionV2.Service
  130. interruptCalls.length = 0
  131. yield* session.interrupt(sessionID)
  132. expect(interruptCalls).toEqual([sessionID])
  133. expect(yield* session.messages({ sessionID })).toEqual([])
  134. }),
  135. )
  136. it.effect("delegates interruption without requiring a recorded Session", () =>
  137. Effect.gen(function* () {
  138. const session = yield* SessionV2.Service
  139. interruptCalls.length = 0
  140. yield* session.interrupt(SessionV2.ID.make("ses_missing"))
  141. expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")])
  142. }),
  143. )
  144. it.effect("durably admits one user message before transcript promotion", () =>
  145. Effect.gen(function* () {
  146. yield* setup
  147. const session = yield* SessionV2.Service
  148. const message = yield* session.prompt({
  149. sessionID,
  150. prompt: Prompt.make({ text: "Fix the failing tests" }),
  151. resume: false,
  152. })
  153. expect(message.prompt.text).toBe("Fix the failing tests")
  154. expect(yield* session.messages({ sessionID })).toEqual([])
  155. expect(yield* admitted(message.id)).toMatchObject({
  156. id: message.id,
  157. sessionID,
  158. prompt: { text: "Fix the failing tests" },
  159. delivery: "steer",
  160. })
  161. }),
  162. )
  163. it.effect("streams durable Session events after an aggregate sequence", () =>
  164. Effect.gen(function* () {
  165. yield* setup
  166. const session = yield* SessionV2.Service
  167. const events = yield* EventV2.Service
  168. const { db } = yield* Database.Service
  169. const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
  170. yield* Effect.yieldNow
  171. yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
  172. yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
  173. yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
  174. const streamed = Array.from(yield* Fiber.join(fiber))
  175. expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
  176. [0, "session.next.prompt.admitted"],
  177. [1, "session.next.prompt.admitted"],
  178. [2, "session.next.prompted"],
  179. [3, "session.next.prompted"],
  180. ])
  181. expect(
  182. Array.from(
  183. yield* session
  184. .events({ sessionID, after: streamed[0]!.durable?.seq })
  185. .pipe(Stream.take(1), Stream.runCollect),
  186. ).map((event) => [event.durable?.seq, event.type]),
  187. ).toEqual([[1, "session.next.prompt.admitted"]])
  188. }),
  189. )
  190. it.effect("resumes through a recorded message without appending another prompt", () =>
  191. Effect.gen(function* () {
  192. yield* setup
  193. const session = yield* SessionV2.Service
  194. const message = yield* session.prompt({
  195. sessionID,
  196. prompt: Prompt.make({ text: "Fix the failing tests" }),
  197. resume: false,
  198. })
  199. executionCalls.length = 0
  200. wakeCalls.length = 0
  201. yield* session.resume(sessionID)
  202. expect(yield* session.messages({ sessionID })).toEqual([])
  203. expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
  204. expect(executionCalls).toEqual([sessionID])
  205. expect(wakeCalls).toEqual([])
  206. }),
  207. )
  208. it.effect("records distinct messages when the ID is omitted", () =>
  209. Effect.gen(function* () {
  210. yield* setup
  211. const session = yield* SessionV2.Service
  212. const input = { sessionID, prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false }
  213. const first = yield* session.prompt(input)
  214. const second = yield* session.prompt(input)
  215. expect(second.id).not.toBe(first.id)
  216. expect(yield* session.messages({ sessionID })).toEqual([])
  217. expect(yield* admittedCount).toBe(2)
  218. }),
  219. )
  220. it.effect("returns the original recorded message when the ID is retried", () =>
  221. Effect.gen(function* () {
  222. yield* setup
  223. const session = yield* SessionV2.Service
  224. const input = {
  225. sessionID,
  226. id: messageID,
  227. prompt: Prompt.make({ text: "Fix the failing tests" }),
  228. resume: false,
  229. }
  230. const first = yield* session.prompt(input)
  231. const retried = yield* session.prompt(input)
  232. expect(retried).toEqual(first)
  233. expect(yield* session.messages({ sessionID })).toEqual([])
  234. expect(yield* admittedCount).toBe(1)
  235. }),
  236. )
  237. it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
  238. Effect.gen(function* () {
  239. yield* setup
  240. const session = yield* SessionV2.Service
  241. const input = {
  242. sessionID,
  243. id: messageID,
  244. prompt: Prompt.make({ text: "Recover committed prompt" }),
  245. resume: false,
  246. }
  247. const first = yield* session.prompt(input)
  248. wakeCalls.length = 0
  249. const retried = yield* session.prompt({ ...input, resume: true })
  250. expect(retried).toEqual(first)
  251. expect(wakeCalls).toEqual([sessionID])
  252. }),
  253. )
  254. it.effect("rejects reuse of one ID with a different prompt", () =>
  255. Effect.gen(function* () {
  256. yield* setup
  257. const session = yield* SessionV2.Service
  258. yield* session.prompt({
  259. sessionID,
  260. id: messageID,
  261. prompt: Prompt.make({ text: "Fix the failing tests" }),
  262. })
  263. const failure = yield* session
  264. .prompt({
  265. sessionID,
  266. id: messageID,
  267. prompt: Prompt.make({ text: "Delete the failing tests" }),
  268. resume: false,
  269. })
  270. .pipe(Effect.flip)
  271. expect(failure._tag).toBe("Session.PromptConflictError")
  272. expect(yield* session.messages({ sessionID })).toHaveLength(0)
  273. expect(yield* admittedCount).toBe(1)
  274. }),
  275. )
  276. it.effect("rejects reuse of one ID with a different delivery mode", () =>
  277. Effect.gen(function* () {
  278. yield* setup
  279. const session = yield* SessionV2.Service
  280. yield* session.prompt({
  281. id: messageID,
  282. sessionID,
  283. prompt: Prompt.make({ text: "Fix the failing tests" }),
  284. resume: false,
  285. })
  286. const failure = yield* session
  287. .prompt({
  288. id: messageID,
  289. sessionID,
  290. prompt: Prompt.make({ text: "Fix the failing tests" }),
  291. delivery: "queue",
  292. resume: false,
  293. })
  294. .pipe(Effect.flip)
  295. expect(failure._tag).toBe("Session.PromptConflictError")
  296. }),
  297. )
  298. it.effect("returns one recorded message to concurrent exact retries", () =>
  299. Effect.gen(function* () {
  300. yield* setup
  301. const session = yield* SessionV2.Service
  302. const input = {
  303. sessionID,
  304. id: messageID,
  305. prompt: Prompt.make({ text: "Fix the failing tests" }),
  306. resume: false,
  307. }
  308. const messages = yield* Effect.all([session.prompt(input), session.prompt(input)], { concurrency: "unbounded" })
  309. expect(messages[1]).toEqual(messages[0])
  310. expect(yield* session.messages({ sessionID })).toEqual([])
  311. expect(yield* admittedCount).toBe(1)
  312. expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1))).toBe(1)
  313. }),
  314. )
  315. it.effect("promotes one message once under concurrent promotion attempts", () =>
  316. Effect.gen(function* () {
  317. yield* setup
  318. const { db } = yield* Database.Service
  319. const session = yield* SessionV2.Service
  320. const events = yield* EventV2.Service
  321. yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false })
  322. yield* Effect.all(
  323. [
  324. SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
  325. SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
  326. ],
  327. { concurrency: "unbounded" },
  328. )
  329. expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1)
  330. expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
  331. expect(yield* session.messages({ sessionID })).toMatchObject([
  332. { id: messageID, type: "user", text: "Promote once" },
  333. ])
  334. }),
  335. )
  336. it.effect("promotes steers only through the captured inbox cutoff", () =>
  337. Effect.gen(function* () {
  338. yield* setup
  339. const { db } = yield* Database.Service
  340. const session = yield* SessionV2.Service
  341. const events = yield* EventV2.Service
  342. const first = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Before cutoff" }), resume: false })
  343. const cutoff = first.admittedSeq
  344. const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false })
  345. yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
  346. expect(yield* admitted(first.id)).toHaveProperty("promotedSeq")
  347. expect(yield* admitted(second.id)).not.toHaveProperty("promotedSeq")
  348. }),
  349. )
  350. it.effect("reprojects pending inbox input without scheduling execution", () =>
  351. Effect.gen(function* () {
  352. yield* setup
  353. const { db } = yield* Database.Service
  354. const session = yield* SessionV2.Service
  355. const events = yield* EventV2.Service
  356. wakeCalls.length = 0
  357. yield* session.prompt({
  358. id: messageID,
  359. sessionID,
  360. prompt: Prompt.make({ text: "Replay pending" }),
  361. resume: false,
  362. })
  363. const recorded = yield* db
  364. .select()
  365. .from(EventTable)
  366. .where(eq(EventTable.aggregate_id, sessionID))
  367. .all()
  368. .pipe(Effect.orDie)
  369. yield* events.remove(sessionID)
  370. yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run().pipe(Effect.orDie)
  371. yield* db
  372. .delete(SessionMessageTable)
  373. .where(eq(SessionMessageTable.session_id, sessionID))
  374. .run()
  375. .pipe(Effect.orDie)
  376. yield* events.replayAll(
  377. recorded.map((event) => ({
  378. id: event.id,
  379. aggregateID: event.aggregate_id,
  380. seq: event.seq,
  381. type: event.type,
  382. data: event.data,
  383. })),
  384. )
  385. expect(yield* admitted(messageID)).toMatchObject({ id: messageID, prompt: { text: "Replay pending" } })
  386. expect(yield* session.messages({ sessionID })).toEqual([])
  387. expect(wakeCalls).toEqual([])
  388. }),
  389. )
  390. it.effect("returns an exact retry of a legacy projected prompt", () =>
  391. Effect.gen(function* () {
  392. yield* setup
  393. const session = yield* SessionV2.Service
  394. const events = yield* EventV2.Service
  395. const prompt = Prompt.make({ text: "Historical prompt" })
  396. yield* events.publish(SessionEvent.Prompted, {
  397. sessionID,
  398. messageID,
  399. timestamp: yield* DateTime.now,
  400. prompt,
  401. delivery: "steer",
  402. })
  403. const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
  404. expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical prompt" } })
  405. expect(yield* admitted(messageID)).toHaveProperty("promotedSeq")
  406. }),
  407. )
  408. it.effect("returns an exact retry of a legacy projected queued prompt", () =>
  409. Effect.gen(function* () {
  410. yield* setup
  411. const session = yield* SessionV2.Service
  412. const events = yield* EventV2.Service
  413. const prompt = Prompt.make({ text: "Historical queued prompt" })
  414. yield* events.publish(SessionEvent.Prompted, {
  415. sessionID,
  416. messageID,
  417. timestamp: yield* DateTime.now,
  418. prompt,
  419. delivery: "queue",
  420. })
  421. const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false })
  422. expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical queued prompt" } })
  423. expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" })
  424. }),
  425. )
  426. it.effect("rejects reuse of one globally unique message ID across sessions", () =>
  427. Effect.gen(function* () {
  428. yield* setup
  429. const { db } = yield* Database.Service
  430. const session = yield* SessionV2.Service
  431. const other = SessionV2.ID.make("ses_prompt_other")
  432. yield* db
  433. .insert(SessionTable)
  434. .values({
  435. id: other,
  436. project_id: Project.ID.global,
  437. slug: "other",
  438. directory: "/project",
  439. title: "other",
  440. version: "test",
  441. })
  442. .onConflictDoNothing()
  443. .run()
  444. .pipe(Effect.orDie)
  445. const prompt = Prompt.make({ text: "Fix the failing tests" })
  446. yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
  447. const failure = yield* session
  448. .prompt({ id: messageID, sessionID: other, prompt, resume: false })
  449. .pipe(Effect.flip)
  450. expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: other, messageID })
  451. }),
  452. )
  453. it.effect("rejects a prompt ID already used by visible Session history", () =>
  454. Effect.gen(function* () {
  455. yield* setup
  456. const session = yield* SessionV2.Service
  457. const events = yield* EventV2.Service
  458. yield* events.publish(SessionEvent.Synthetic, {
  459. sessionID,
  460. messageID,
  461. timestamp: yield* DateTime.now,
  462. text: "Existing history",
  463. })
  464. const failure = yield* session
  465. .prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false })
  466. .pipe(Effect.flip)
  467. expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
  468. expect(yield* admitted(messageID)).toBeUndefined()
  469. }),
  470. )
  471. it.effect("starts execution by default after recording the prompt", () =>
  472. Effect.gen(function* () {
  473. yield* setup
  474. const session = yield* SessionV2.Service
  475. executionCalls.length = 0
  476. wakeCalls.length = 0
  477. yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run by default" }) })
  478. expect(executionCalls).toEqual([])
  479. expect(wakeCalls).toEqual([sessionID])
  480. }),
  481. )
  482. it.effect("starts execution when resume is explicitly true", () =>
  483. Effect.gen(function* () {
  484. yield* setup
  485. const session = yield* SessionV2.Service
  486. executionCalls.length = 0
  487. wakeCalls.length = 0
  488. yield* session.prompt({
  489. sessionID,
  490. prompt: Prompt.make({ text: "Run explicitly" }),
  491. resume: true,
  492. })
  493. expect(executionCalls).toEqual([])
  494. expect(wakeCalls).toEqual([sessionID])
  495. }),
  496. )
  497. it.effect("only records the prompt when resume is false", () =>
  498. Effect.gen(function* () {
  499. yield* setup
  500. const session = yield* SessionV2.Service
  501. executionCalls.length = 0
  502. wakeCalls.length = 0
  503. yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false })
  504. expect(executionCalls).toEqual([])
  505. expect(wakeCalls).toEqual([])
  506. }),
  507. )
  508. })