session-prompt.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  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 { locationServiceMapLayer } from "@opencode-ai/core/location-services"
  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(locationServiceMapLayer),
  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("resolves attachment MIME before admission", () =>
  164. Effect.gen(function* () {
  165. yield* setup
  166. const session = yield* SessionV2.Service
  167. const message = yield* session.prompt({
  168. sessionID,
  169. prompt: {
  170. text: "Inspect this image",
  171. files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }],
  172. },
  173. resume: false,
  174. })
  175. expect(message.prompt.files).toEqual([
  176. { uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" },
  177. ])
  178. expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
  179. }),
  180. )
  181. it.effect("streams durable Session events after an aggregate sequence", () =>
  182. Effect.gen(function* () {
  183. yield* setup
  184. const session = yield* SessionV2.Service
  185. const events = yield* EventV2.Service
  186. const { db } = yield* Database.Service
  187. const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
  188. yield* Effect.yieldNow
  189. yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
  190. yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
  191. yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
  192. const streamed = Array.from(yield* Fiber.join(fiber))
  193. expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
  194. [0, "session.next.prompt.admitted"],
  195. [1, "session.next.prompt.admitted"],
  196. [2, "session.next.prompted"],
  197. [3, "session.next.prompted"],
  198. ])
  199. expect(
  200. Array.from(
  201. yield* session
  202. .events({ sessionID, after: streamed[0]!.durable?.seq })
  203. .pipe(Stream.take(1), Stream.runCollect),
  204. ).map((event) => [event.durable?.seq, event.type]),
  205. ).toEqual([[1, "session.next.prompt.admitted"]])
  206. }),
  207. )
  208. it.effect("resumes through a recorded message without appending another prompt", () =>
  209. Effect.gen(function* () {
  210. yield* setup
  211. const session = yield* SessionV2.Service
  212. const message = yield* session.prompt({
  213. sessionID,
  214. prompt: Prompt.make({ text: "Fix the failing tests" }),
  215. resume: false,
  216. })
  217. executionCalls.length = 0
  218. wakeCalls.length = 0
  219. yield* session.resume(sessionID)
  220. expect(yield* session.messages({ sessionID })).toEqual([])
  221. expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
  222. expect(executionCalls).toEqual([sessionID])
  223. expect(wakeCalls).toEqual([])
  224. }),
  225. )
  226. it.effect("records distinct messages when the ID is omitted", () =>
  227. Effect.gen(function* () {
  228. yield* setup
  229. const session = yield* SessionV2.Service
  230. const input = { sessionID, prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false }
  231. const first = yield* session.prompt(input)
  232. const second = yield* session.prompt(input)
  233. expect(second.id).not.toBe(first.id)
  234. expect(yield* session.messages({ sessionID })).toEqual([])
  235. expect(yield* admittedCount).toBe(2)
  236. }),
  237. )
  238. it.effect("returns the original recorded message when the ID is retried", () =>
  239. Effect.gen(function* () {
  240. yield* setup
  241. const session = yield* SessionV2.Service
  242. const input = {
  243. sessionID,
  244. id: messageID,
  245. prompt: Prompt.make({ text: "Fix the failing tests" }),
  246. resume: false,
  247. }
  248. const first = yield* session.prompt(input)
  249. const retried = yield* session.prompt(input)
  250. expect(retried).toEqual(first)
  251. expect(yield* session.messages({ sessionID })).toEqual([])
  252. expect(yield* admittedCount).toBe(1)
  253. }),
  254. )
  255. it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
  256. Effect.gen(function* () {
  257. yield* setup
  258. const session = yield* SessionV2.Service
  259. const input = {
  260. sessionID,
  261. id: messageID,
  262. prompt: Prompt.make({ text: "Recover committed prompt" }),
  263. resume: false,
  264. }
  265. const first = yield* session.prompt(input)
  266. wakeCalls.length = 0
  267. const retried = yield* session.prompt({ ...input, resume: true })
  268. expect(retried).toEqual(first)
  269. expect(wakeCalls).toEqual([sessionID])
  270. }),
  271. )
  272. it.effect("rejects reuse of one ID with a different prompt", () =>
  273. Effect.gen(function* () {
  274. yield* setup
  275. const session = yield* SessionV2.Service
  276. yield* session.prompt({
  277. sessionID,
  278. id: messageID,
  279. prompt: Prompt.make({ text: "Fix the failing tests" }),
  280. })
  281. const failure = yield* session
  282. .prompt({
  283. sessionID,
  284. id: messageID,
  285. prompt: Prompt.make({ text: "Delete the failing tests" }),
  286. resume: false,
  287. })
  288. .pipe(Effect.flip)
  289. expect(failure._tag).toBe("Session.PromptConflictError")
  290. expect(yield* session.messages({ sessionID })).toHaveLength(0)
  291. expect(yield* admittedCount).toBe(1)
  292. }),
  293. )
  294. it.effect("rejects reuse of one ID with a different delivery mode", () =>
  295. Effect.gen(function* () {
  296. yield* setup
  297. const session = yield* SessionV2.Service
  298. yield* session.prompt({
  299. id: messageID,
  300. sessionID,
  301. prompt: Prompt.make({ text: "Fix the failing tests" }),
  302. resume: false,
  303. })
  304. const failure = yield* session
  305. .prompt({
  306. id: messageID,
  307. sessionID,
  308. prompt: Prompt.make({ text: "Fix the failing tests" }),
  309. delivery: "queue",
  310. resume: false,
  311. })
  312. .pipe(Effect.flip)
  313. expect(failure._tag).toBe("Session.PromptConflictError")
  314. }),
  315. )
  316. it.effect("returns one recorded message to concurrent exact retries", () =>
  317. Effect.gen(function* () {
  318. yield* setup
  319. const session = yield* SessionV2.Service
  320. const input = {
  321. sessionID,
  322. id: messageID,
  323. prompt: Prompt.make({ text: "Fix the failing tests" }),
  324. resume: false,
  325. }
  326. const messages = yield* Effect.all([session.prompt(input), session.prompt(input)], { concurrency: "unbounded" })
  327. expect(messages[1]).toEqual(messages[0])
  328. expect(yield* session.messages({ sessionID })).toEqual([])
  329. expect(yield* admittedCount).toBe(1)
  330. expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1))).toBe(1)
  331. }),
  332. )
  333. it.effect("promotes one message once under concurrent promotion attempts", () =>
  334. Effect.gen(function* () {
  335. yield* setup
  336. const { db } = yield* Database.Service
  337. const session = yield* SessionV2.Service
  338. const events = yield* EventV2.Service
  339. yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false })
  340. yield* Effect.all(
  341. [
  342. SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
  343. SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
  344. ],
  345. { concurrency: "unbounded" },
  346. )
  347. expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1)
  348. expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
  349. expect(yield* session.messages({ sessionID })).toMatchObject([
  350. { id: messageID, type: "user", text: "Promote once" },
  351. ])
  352. }),
  353. )
  354. it.effect("promotes steers only through the captured inbox cutoff", () =>
  355. Effect.gen(function* () {
  356. yield* setup
  357. const { db } = yield* Database.Service
  358. const session = yield* SessionV2.Service
  359. const events = yield* EventV2.Service
  360. const first = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Before cutoff" }), resume: false })
  361. const cutoff = first.admittedSeq
  362. const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false })
  363. yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
  364. expect(yield* admitted(first.id)).toHaveProperty("promotedSeq")
  365. expect(yield* admitted(second.id)).not.toHaveProperty("promotedSeq")
  366. }),
  367. )
  368. it.effect("reprojects pending inbox input without scheduling execution", () =>
  369. Effect.gen(function* () {
  370. yield* setup
  371. const { db } = yield* Database.Service
  372. const session = yield* SessionV2.Service
  373. const events = yield* EventV2.Service
  374. wakeCalls.length = 0
  375. yield* session.prompt({
  376. id: messageID,
  377. sessionID,
  378. prompt: Prompt.make({ text: "Replay pending" }),
  379. resume: false,
  380. })
  381. const recorded = yield* db
  382. .select()
  383. .from(EventTable)
  384. .where(eq(EventTable.aggregate_id, sessionID))
  385. .all()
  386. .pipe(Effect.orDie)
  387. yield* events.remove(sessionID)
  388. yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run().pipe(Effect.orDie)
  389. yield* db
  390. .delete(SessionMessageTable)
  391. .where(eq(SessionMessageTable.session_id, sessionID))
  392. .run()
  393. .pipe(Effect.orDie)
  394. yield* events.replayAll(
  395. recorded.map((event) => ({
  396. id: event.id,
  397. aggregateID: event.aggregate_id,
  398. seq: event.seq,
  399. type: event.type,
  400. data: event.data,
  401. })),
  402. )
  403. expect(yield* admitted(messageID)).toMatchObject({ id: messageID, prompt: { text: "Replay pending" } })
  404. expect(yield* session.messages({ sessionID })).toEqual([])
  405. expect(wakeCalls).toEqual([])
  406. }),
  407. )
  408. it.effect("returns an exact retry of a legacy projected 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 prompt" })
  414. yield* events.publish(SessionEvent.Prompted, {
  415. sessionID,
  416. messageID,
  417. timestamp: yield* DateTime.now,
  418. prompt,
  419. delivery: "steer",
  420. })
  421. const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
  422. expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical prompt" } })
  423. expect(yield* admitted(messageID)).toHaveProperty("promotedSeq")
  424. }),
  425. )
  426. it.effect("returns an exact retry of a legacy projected queued prompt", () =>
  427. Effect.gen(function* () {
  428. yield* setup
  429. const session = yield* SessionV2.Service
  430. const events = yield* EventV2.Service
  431. const prompt = Prompt.make({ text: "Historical queued prompt" })
  432. yield* events.publish(SessionEvent.Prompted, {
  433. sessionID,
  434. messageID,
  435. timestamp: yield* DateTime.now,
  436. prompt,
  437. delivery: "queue",
  438. })
  439. const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false })
  440. expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical queued prompt" } })
  441. expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" })
  442. }),
  443. )
  444. it.effect("rejects reuse of one globally unique message ID across sessions", () =>
  445. Effect.gen(function* () {
  446. yield* setup
  447. const { db } = yield* Database.Service
  448. const session = yield* SessionV2.Service
  449. const other = SessionV2.ID.make("ses_prompt_other")
  450. yield* db
  451. .insert(SessionTable)
  452. .values({
  453. id: other,
  454. project_id: Project.ID.global,
  455. slug: "other",
  456. directory: "/project",
  457. title: "other",
  458. version: "test",
  459. })
  460. .onConflictDoNothing()
  461. .run()
  462. .pipe(Effect.orDie)
  463. const prompt = Prompt.make({ text: "Fix the failing tests" })
  464. yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
  465. const failure = yield* session
  466. .prompt({ id: messageID, sessionID: other, prompt, resume: false })
  467. .pipe(Effect.flip)
  468. expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: other, messageID })
  469. }),
  470. )
  471. it.effect("rejects a prompt ID already used by visible Session history", () =>
  472. Effect.gen(function* () {
  473. yield* setup
  474. const session = yield* SessionV2.Service
  475. const events = yield* EventV2.Service
  476. yield* events.publish(SessionEvent.Synthetic, {
  477. sessionID,
  478. messageID,
  479. timestamp: yield* DateTime.now,
  480. text: "Existing history",
  481. })
  482. const failure = yield* session
  483. .prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false })
  484. .pipe(Effect.flip)
  485. expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
  486. expect(yield* admitted(messageID)).toBeUndefined()
  487. }),
  488. )
  489. it.effect("starts execution by default after recording the prompt", () =>
  490. Effect.gen(function* () {
  491. yield* setup
  492. const session = yield* SessionV2.Service
  493. executionCalls.length = 0
  494. wakeCalls.length = 0
  495. yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run by default" }) })
  496. expect(executionCalls).toEqual([])
  497. expect(wakeCalls).toEqual([sessionID])
  498. }),
  499. )
  500. it.effect("starts execution when resume is explicitly true", () =>
  501. Effect.gen(function* () {
  502. yield* setup
  503. const session = yield* SessionV2.Service
  504. executionCalls.length = 0
  505. wakeCalls.length = 0
  506. yield* session.prompt({
  507. sessionID,
  508. prompt: Prompt.make({ text: "Run explicitly" }),
  509. resume: true,
  510. })
  511. expect(executionCalls).toEqual([])
  512. expect(wakeCalls).toEqual([sessionID])
  513. }),
  514. )
  515. it.effect("only records the prompt when resume is false", () =>
  516. Effect.gen(function* () {
  517. yield* setup
  518. const session = yield* SessionV2.Service
  519. executionCalls.length = 0
  520. wakeCalls.length = 0
  521. yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false })
  522. expect(executionCalls).toEqual([])
  523. expect(wakeCalls).toEqual([])
  524. }),
  525. )
  526. })