session-prompt.test.ts 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163
  1. import { describe, expect } from "bun:test"
  2. import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
  3. import { mkdtemp, rm } from "fs/promises"
  4. import { tmpdir } from "os"
  5. import path from "path"
  6. import { pathToFileURL } from "url"
  7. import { eq } from "drizzle-orm"
  8. import { Database } from "@opencode-ai/core/database/database"
  9. import { Agent } from "@opencode-ai/core/agent"
  10. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  11. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  12. import { Bus } from "@opencode-ai/core/bus"
  13. import { EventTable } from "@opencode-ai/core/event/sql"
  14. import { SessionEvent } from "@opencode-ai/core/session/event"
  15. import { Model } from "@opencode-ai/core/model"
  16. import { Provider } from "@opencode-ai/core/provider"
  17. import { Project } from "@opencode-ai/core/project"
  18. import { ProjectTable } from "@opencode-ai/core/project/sql"
  19. import { AbsolutePath } from "@opencode-ai/core/schema"
  20. import { Session } from "@opencode-ai/core/session"
  21. import { SessionMessage } from "@opencode-ai/core/session/message"
  22. import { SessionProjector } from "@opencode-ai/core/session/projector"
  23. import { SessionExecution } from "@opencode-ai/core/session/execution"
  24. import { SessionPending } from "@opencode-ai/core/session/pending"
  25. import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
  26. import { SessionStore } from "@opencode-ai/core/session/store"
  27. import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
  28. import type { LocationServices } from "@opencode-ai/core/location-services"
  29. import { Image } from "@opencode-ai/core/image"
  30. import { testEffect } from "./lib/effect"
  31. const executionCalls: Session.ID[] = []
  32. const interruptCalls: Session.ID[] = []
  33. const wakeCalls: Session.ID[] = []
  34. const activeSessions = new Set<Session.ID>()
  35. const execution = Layer.succeed(
  36. SessionExecution.Service,
  37. SessionExecution.Service.of({
  38. active: Effect.sync(() => new Set(activeSessions)),
  39. resume: (sessionID) =>
  40. Effect.sync(() => {
  41. executionCalls.push(sessionID)
  42. }),
  43. interrupt: (sessionID) =>
  44. Effect.sync(() => {
  45. interruptCalls.push(sessionID)
  46. }),
  47. wake: (sessionID) =>
  48. Effect.sync(() => {
  49. wakeCalls.push(sessionID)
  50. }),
  51. awaitIdle: () => Effect.void,
  52. }),
  53. )
  54. const locations = Layer.effect(
  55. LocationServiceMap.Service,
  56. LayerMap.make(
  57. () =>
  58. // Attachment admission only needs the location-scoped Image service.
  59. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
  60. Layer.mock(Image.Service, {
  61. normalize: (_resource, content) =>
  62. Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content),
  63. }) as unknown as Layer.Layer<LocationServices>,
  64. ),
  65. )
  66. const it = testEffect(
  67. AppNodeBuilder.build(
  68. LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
  69. [
  70. [Bus.node, Bus.configured({ persist: true })],
  71. [SessionExecution.node, execution],
  72. [LocationServiceMap.node, locations],
  73. ],
  74. ),
  75. )
  76. const sessionID = Session.ID.make("ses_prompt_test")
  77. const messageID = SessionMessage.ID.create()
  78. const setup = Effect.gen(function* () {
  79. const { db } = yield* Database.Service
  80. yield* db
  81. .insert(ProjectTable)
  82. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  83. .onConflictDoNothing()
  84. .run()
  85. .pipe(Effect.orDie)
  86. yield* db
  87. .insert(SessionTable)
  88. .values({
  89. id: sessionID,
  90. project_id: Project.ID.global,
  91. slug: "test",
  92. directory: "/project",
  93. title: "test",
  94. version: "test",
  95. })
  96. .onConflictDoNothing()
  97. .run()
  98. .pipe(Effect.orDie)
  99. })
  100. const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionPending.find(db, id))
  101. const admittedCount = Database.Service.use(({ db }) =>
  102. db
  103. .select()
  104. .from(SessionPendingTable)
  105. .all()
  106. .pipe(
  107. Effect.orDie,
  108. Effect.map((rows) => rows.length),
  109. ),
  110. )
  111. const eventCount = (type: string) =>
  112. Database.Service.use(({ db }) =>
  113. db
  114. .select()
  115. .from(EventTable)
  116. .where(eq(EventTable.type, type))
  117. .all()
  118. .pipe(
  119. Effect.orDie,
  120. Effect.map((rows) => rows.length),
  121. ),
  122. )
  123. const encodeMessage = Schema.encodeSync(SessionMessage.Info)
  124. const assistantRow = (id: SessionMessage.ID, seq: number) => {
  125. const {
  126. id: _,
  127. type,
  128. ...data
  129. } = encodeMessage(
  130. SessionMessage.Assistant.make({
  131. id,
  132. type: "assistant",
  133. agent: Agent.ID.make("build"),
  134. model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
  135. content: [],
  136. time: { created: DateTime.makeUnsafe(0) },
  137. }),
  138. )
  139. return { id, session_id: sessionID, type, seq, time_created: 0, data }
  140. }
  141. describe("Session.prompt", () => {
  142. it.effect("exposes the execution registry", () =>
  143. Effect.gen(function* () {
  144. activeSessions.add(sessionID)
  145. expect(Array.from(yield* (yield* Session.Service).active)).toEqual([sessionID])
  146. }).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
  147. )
  148. it.effect("delegates execution continuation through SessionExecution", () =>
  149. Effect.gen(function* () {
  150. yield* setup
  151. const session = yield* Session.Service
  152. executionCalls.length = 0
  153. wakeCalls.length = 0
  154. yield* session.resume(sessionID)
  155. expect(executionCalls).toEqual([sessionID])
  156. expect(wakeCalls).toEqual([])
  157. }),
  158. )
  159. it.effect("delegates process-local interruption through SessionExecution", () =>
  160. Effect.gen(function* () {
  161. yield* setup
  162. const session = yield* Session.Service
  163. interruptCalls.length = 0
  164. yield* session.interrupt(sessionID)
  165. expect(interruptCalls).toEqual([sessionID])
  166. expect(yield* session.messages({ sessionID })).toEqual([])
  167. }),
  168. )
  169. it.effect("delegates interruption without requiring a recorded Session", () =>
  170. Effect.gen(function* () {
  171. const session = yield* Session.Service
  172. interruptCalls.length = 0
  173. yield* session.interrupt(Session.ID.make("ses_missing"))
  174. expect(interruptCalls).toEqual([Session.ID.make("ses_missing")])
  175. }),
  176. )
  177. it.effect("durably admits one user message before transcript promotion", () =>
  178. Effect.gen(function* () {
  179. yield* setup
  180. const session = yield* Session.Service
  181. const message = yield* session.prompt({
  182. sessionID,
  183. text: "Fix the failing tests",
  184. resume: false,
  185. })
  186. expect(message.data.text).toBe("Fix the failing tests")
  187. expect(yield* session.messages({ sessionID })).toEqual([])
  188. expect(yield* admitted(message.id)).toMatchObject({
  189. id: message.id,
  190. sessionID,
  191. type: "user",
  192. data: { text: "Fix the failing tests" },
  193. delivery: "steer",
  194. })
  195. }),
  196. )
  197. it.effect("commits a staged revert before admitting a new prompt", () =>
  198. Effect.gen(function* () {
  199. yield* setup
  200. const session = yield* Session.Service
  201. const bus = yield* Bus.Service
  202. const { db } = yield* Database.Service
  203. const boundary = yield* session.prompt({
  204. sessionID,
  205. text: "boundary",
  206. resume: false,
  207. })
  208. yield* SessionPending.promote(db, bus, sessionID, "steer")
  209. const stale = SessionMessage.ID.make("msg_stale_assistant")
  210. yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
  211. yield* bus.publish(SessionEvent.RevertEvent.Staged, {
  212. sessionID,
  213. revert: { messageID: boundary.id, files: [] },
  214. })
  215. expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id)
  216. yield* session.prompt({ sessionID, text: "after revert", resume: false })
  217. expect((yield* session.get(sessionID)).revert).toBeUndefined()
  218. expect(
  219. (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all().pipe(Effect.orDie)).map(
  220. (row) => row.id,
  221. ),
  222. ).not.toContainAnyValues([boundary.id, stale])
  223. expect(yield* SessionPending.find(db, boundary.id)).toBeUndefined()
  224. }),
  225. )
  226. it.effect("holds synthetic input behind a staged revert and discards it when committed", () =>
  227. Effect.gen(function* () {
  228. yield* setup
  229. const session = yield* Session.Service
  230. const bus = yield* Bus.Service
  231. const { db } = yield* Database.Service
  232. const boundary = yield* session.prompt({
  233. sessionID,
  234. text: "boundary",
  235. resume: false,
  236. })
  237. yield* SessionPending.promote(db, bus, sessionID, "steer")
  238. yield* bus.publish(SessionEvent.RevertEvent.Staged, {
  239. sessionID,
  240. revert: { messageID: boundary.id, files: [] },
  241. })
  242. wakeCalls.length = 0
  243. const completion = yield* session.synthetic({ sessionID, text: "stale completion" })
  244. expect(wakeCalls).toEqual([])
  245. expect(yield* SessionPending.find(db, completion.id)).toMatchObject({ type: "synthetic" })
  246. yield* session.revert.commit(sessionID)
  247. expect(yield* SessionPending.find(db, completion.id)).toBeUndefined()
  248. }),
  249. )
  250. it.effect("resolves attachment MIME before admission", () =>
  251. Effect.gen(function* () {
  252. yield* setup
  253. const session = yield* Session.Service
  254. const uri =
  255. "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  256. const message = yield* session.prompt({
  257. sessionID,
  258. text: "Inspect this image",
  259. files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }],
  260. resume: false,
  261. })
  262. expect(message.data.files).toEqual([
  263. {
  264. data: uri.slice(uri.indexOf(",") + 1),
  265. mime: "image/png",
  266. source: { type: "inline" },
  267. name: "image.png",
  268. mention: { start: 8, end: 17, text: "[Image 1]" },
  269. },
  270. ])
  271. const stored = yield* admitted(message.id)
  272. expect(stored?.type).toBe("user")
  273. if (stored?.type === "user") expect(stored.data.files).toEqual(message.data.files)
  274. }),
  275. )
  276. it.effect("materializes selected source file content", () =>
  277. Effect.gen(function* () {
  278. yield* setup
  279. const session = yield* Session.Service
  280. const directory = import.meta.dir
  281. const source = path.join(directory, "session-prompt.test.ts")
  282. const sourceUri = pathToFileURL(source)
  283. sourceUri.searchParams.set("start", "1")
  284. sourceUri.searchParams.set("end", "1")
  285. const message = yield* session.prompt({
  286. sessionID,
  287. text: "Inspect this",
  288. files: [{ uri: sourceUri.href, name: "main.ts" }],
  289. resume: false,
  290. })
  291. expect(message.data.files).toHaveLength(1)
  292. expect(message.data.files?.[0]).toMatchObject({
  293. mime: "text/plain",
  294. source: { type: "uri", uri: sourceUri.href },
  295. name: "main.ts",
  296. })
  297. expect(
  298. Buffer.from(message.data.files?.[0]?.data ?? "", "base64")
  299. .toString("utf8")
  300. .replace(/\r$/, ""),
  301. ).toBe('import { describe, expect } from "bun:test"')
  302. }),
  303. )
  304. it.effect("materializes directories as directory attachments", () =>
  305. Effect.gen(function* () {
  306. yield* setup
  307. const session = yield* Session.Service
  308. const uri = pathToFileURL(import.meta.dir).href
  309. const message = yield* session.prompt({
  310. sessionID,
  311. text: "Inspect this",
  312. files: [{ uri, name: "source" }],
  313. resume: false,
  314. })
  315. expect(message.data.files).toHaveLength(1)
  316. expect(message.data.files?.[0]).toMatchObject({
  317. mime: "application/x-directory",
  318. source: { type: "uri", uri },
  319. name: "source",
  320. })
  321. expect(Buffer.from(message.data.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
  322. "session-prompt.test.ts",
  323. )
  324. }),
  325. )
  326. it.effect("materializes local image content before admission", () =>
  327. Effect.gen(function* () {
  328. yield* setup
  329. const session = yield* Session.Service
  330. const directory = yield* Effect.acquireRelease(
  331. Effect.promise(() => mkdtemp(path.join(tmpdir(), "opencode-session-prompt-"))),
  332. (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
  333. )
  334. const source = path.join(directory, "image.png")
  335. const bytes = Buffer.from(
  336. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
  337. "base64",
  338. )
  339. yield* Effect.promise(() => Bun.write(source, bytes))
  340. const message = yield* session.prompt({
  341. sessionID,
  342. text: "Inspect this image",
  343. files: [{ uri: pathToFileURL(source).href }],
  344. resume: false,
  345. })
  346. expect(message.data.files).toEqual([
  347. {
  348. data: bytes.toString("base64"),
  349. mime: "image/png",
  350. source: { type: "uri", uri: pathToFileURL(source).href },
  351. name: "image.png",
  352. },
  353. ])
  354. const stored = yield* admitted(message.id)
  355. expect(stored?.type === "user" ? stored.data.files : undefined).toEqual(message.data.files)
  356. }),
  357. )
  358. it.effect("normalizes large image content before validating persisted Base64", () =>
  359. Effect.gen(function* () {
  360. yield* setup
  361. const session = yield* Session.Service
  362. const pixel = Buffer.from(
  363. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
  364. "base64",
  365. )
  366. const bytes = Buffer.concat([pixel, Buffer.alloc(4_323_030 - pixel.length)])
  367. const data = bytes.toString("base64")
  368. expect(data).toHaveLength(5_764_040)
  369. const message = yield* session.prompt({
  370. sessionID,
  371. text: "Inspect this image",
  372. files: [{ uri: `data:image/png;base64,${data}` }],
  373. resume: false,
  374. })
  375. expect(message.data.files).toEqual([
  376. {
  377. data: "AA==",
  378. mime: "image/png",
  379. source: { type: "inline" },
  380. },
  381. ])
  382. }),
  383. )
  384. it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
  385. Effect.gen(function* () {
  386. yield* setup
  387. const session = yield* Session.Service
  388. const uri = `data:video/mp2t;base64,${Buffer.from("export const value = 1\n").toString("base64")}`
  389. const message = yield* session.prompt({
  390. sessionID,
  391. text: "Inspect this",
  392. files: [{ uri, name: "main.ts" }],
  393. resume: false,
  394. })
  395. expect(message.data.files).toEqual([
  396. {
  397. data: Buffer.from("export const value = 1\n").toString("base64"),
  398. mime: "text/plain",
  399. source: { type: "inline" },
  400. name: "main.ts",
  401. },
  402. ])
  403. }),
  404. )
  405. it.effect("rejects malformed base64 data URLs", () =>
  406. Effect.gen(function* () {
  407. yield* setup
  408. const session = yield* Session.Service
  409. const uri = "data:image/png;base64,not-base64"
  410. const error = yield* session
  411. .prompt({
  412. sessionID,
  413. text: "Inspect this",
  414. files: [{ uri, name: "image.png" }],
  415. resume: false,
  416. })
  417. .pipe(Effect.flip)
  418. expect(error).toMatchObject({
  419. _tag: "Session.AttachmentError",
  420. uri,
  421. message: "Invalid attachment data URL",
  422. })
  423. }),
  424. )
  425. it.effect("streams durable Session events after an aggregate sequence", () =>
  426. Effect.gen(function* () {
  427. yield* setup
  428. const session = yield* Session.Service
  429. const bus = yield* Bus.Service
  430. const { db } = yield* Database.Service
  431. const publicEvents = (input: { sessionID: Session.ID; after?: number }) =>
  432. session
  433. .log({ ...input, follow: true })
  434. .pipe(Stream.filter((item): item is SessionEvent.DurableEvent => !Bus.isSynced(item)))
  435. const fiber = yield* publicEvents({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
  436. yield* Effect.yieldNow
  437. yield* session.prompt({ sessionID, text: "First", resume: false })
  438. yield* session.prompt({ sessionID, text: "Second", resume: false })
  439. yield* SessionPending.promote(db, bus, sessionID, "steer")
  440. const streamed = Array.from(yield* Fiber.join(fiber))
  441. expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
  442. [0, "session.input.admitted"],
  443. [1, "session.input.admitted"],
  444. [2, "session.input.promoted"],
  445. [3, "session.input.promoted"],
  446. ])
  447. expect(
  448. Array.from(
  449. yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
  450. ).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
  451. ).toEqual([[1, "session.input.admitted"]])
  452. }),
  453. )
  454. it.effect("resumes through a recorded message without appending another prompt", () =>
  455. Effect.gen(function* () {
  456. yield* setup
  457. const session = yield* Session.Service
  458. const message = yield* session.prompt({
  459. sessionID,
  460. text: "Fix the failing tests",
  461. resume: false,
  462. })
  463. executionCalls.length = 0
  464. wakeCalls.length = 0
  465. yield* session.resume(sessionID)
  466. expect(yield* session.messages({ sessionID })).toEqual([])
  467. expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
  468. expect(executionCalls).toEqual([sessionID])
  469. expect(wakeCalls).toEqual([])
  470. }),
  471. )
  472. it.effect("records distinct messages when the ID is omitted", () =>
  473. Effect.gen(function* () {
  474. yield* setup
  475. const session = yield* Session.Service
  476. const input = { sessionID, text: "Fix the failing tests", resume: false }
  477. const first = yield* session.prompt(input)
  478. const second = yield* session.prompt(input)
  479. expect(second.id).not.toBe(first.id)
  480. expect(yield* session.messages({ sessionID })).toEqual([])
  481. expect(yield* admittedCount).toBe(2)
  482. }),
  483. )
  484. it.effect("returns the original recorded message when the ID is retried", () =>
  485. Effect.gen(function* () {
  486. yield* setup
  487. const session = yield* Session.Service
  488. const input = {
  489. sessionID,
  490. id: messageID,
  491. text: "Fix the failing tests",
  492. resume: false,
  493. }
  494. const first = yield* session.prompt(input)
  495. const retried = yield* session.prompt(input)
  496. expect(retried).toEqual(first)
  497. expect(yield* session.messages({ sessionID })).toEqual([])
  498. expect(yield* admittedCount).toBe(1)
  499. }),
  500. )
  501. it.effect("reconciles an exact retry from the promoted message without admission history", () =>
  502. Effect.gen(function* () {
  503. yield* setup
  504. const session = yield* Session.Service
  505. const bus = yield* Bus.Service
  506. const { db } = yield* Database.Service
  507. const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
  508. const first = yield* session.prompt(input)
  509. yield* SessionPending.promote(db, bus, sessionID, "steer")
  510. yield* db
  511. .delete(EventTable)
  512. .where(eq(EventTable.aggregate_id, sessionID))
  513. .run()
  514. .pipe(Effect.orDie)
  515. const retried = yield* session.prompt(input)
  516. expect(retried).toMatchObject({ id: first.id, type: "user", data: { text: first.data.text } })
  517. expect(yield* session.messages({ sessionID })).toMatchObject([
  518. { id: messageID, type: "user", text: "Fix the failing tests" },
  519. ])
  520. }),
  521. )
  522. it.effect("ignores delivery when retrying a promoted message", () =>
  523. Effect.gen(function* () {
  524. yield* setup
  525. const session = yield* Session.Service
  526. const bus = yield* Bus.Service
  527. const { db } = yield* Database.Service
  528. const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
  529. yield* session.prompt(input)
  530. yield* SessionPending.promote(db, bus, sessionID, "steer")
  531. const retried = yield* session.prompt({ ...input, delivery: "queue" })
  532. expect(retried).toMatchObject({ id: messageID, type: "user", data: { text: input.text } })
  533. expect(yield* admitted(messageID)).toBeUndefined()
  534. }),
  535. )
  536. it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
  537. Effect.gen(function* () {
  538. yield* setup
  539. const session = yield* Session.Service
  540. const input = {
  541. sessionID,
  542. id: messageID,
  543. text: "Recover committed prompt",
  544. resume: false,
  545. }
  546. const first = yield* session.prompt(input)
  547. wakeCalls.length = 0
  548. const retried = yield* session.prompt({ ...input, resume: true })
  549. expect(retried).toEqual(first)
  550. expect(wakeCalls).toEqual([sessionID])
  551. }),
  552. )
  553. it.effect("rejects reuse of one ID with a different prompt", () =>
  554. Effect.gen(function* () {
  555. yield* setup
  556. const session = yield* Session.Service
  557. yield* session.prompt({
  558. sessionID,
  559. id: messageID,
  560. text: "Fix the failing tests",
  561. })
  562. const failure = yield* session
  563. .prompt({
  564. sessionID,
  565. id: messageID,
  566. text: "Delete the failing tests",
  567. resume: false,
  568. })
  569. .pipe(Effect.flip)
  570. expect(failure._tag).toBe("Session.PromptConflictError")
  571. expect(yield* session.messages({ sessionID })).toHaveLength(0)
  572. expect(yield* admittedCount).toBe(1)
  573. }),
  574. )
  575. it.effect("rejects reuse of one ID with a different delivery mode", () =>
  576. Effect.gen(function* () {
  577. yield* setup
  578. const session = yield* Session.Service
  579. yield* session.prompt({
  580. id: messageID,
  581. sessionID,
  582. text: "Fix the failing tests",
  583. resume: false,
  584. })
  585. const failure = yield* session
  586. .prompt({
  587. id: messageID,
  588. sessionID,
  589. text: "Fix the failing tests",
  590. delivery: "queue",
  591. resume: false,
  592. })
  593. .pipe(Effect.flip)
  594. expect(failure._tag).toBe("Session.PromptConflictError")
  595. }),
  596. )
  597. it.effect("returns one recorded message to concurrent exact retries", () =>
  598. Effect.gen(function* () {
  599. yield* setup
  600. const session = yield* Session.Service
  601. const input = {
  602. sessionID,
  603. id: messageID,
  604. text: "Fix the failing tests",
  605. resume: false,
  606. }
  607. const messages = yield* Effect.all([session.prompt(input), session.prompt(input)], { concurrency: "unbounded" })
  608. expect(messages[1]).toEqual(messages[0])
  609. expect(yield* session.messages({ sessionID })).toEqual([])
  610. expect(yield* admittedCount).toBe(1)
  611. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
  612. }),
  613. )
  614. it.effect("promotes one message once under concurrent promotion attempts", () =>
  615. Effect.gen(function* () {
  616. yield* setup
  617. const { db } = yield* Database.Service
  618. const session = yield* Session.Service
  619. const bus = yield* Bus.Service
  620. yield* session.prompt({
  621. id: messageID,
  622. sessionID,
  623. text: "Promote once",
  624. resume: false,
  625. })
  626. yield* Effect.all(
  627. [SessionPending.promote(db, bus, sessionID, "steer"), SessionPending.promote(db, bus, sessionID, "steer")],
  628. { concurrency: "unbounded" },
  629. )
  630. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
  631. expect(yield* admitted(messageID)).toBeUndefined()
  632. expect(yield* session.messages({ sessionID })).toMatchObject([
  633. { id: messageID, type: "user", text: "Promote once" },
  634. ])
  635. }),
  636. )
  637. it.effect("reprojects pending inbox input without scheduling execution", () =>
  638. Effect.gen(function* () {
  639. yield* setup
  640. const { db } = yield* Database.Service
  641. const session = yield* Session.Service
  642. const bus = yield* Bus.Service
  643. wakeCalls.length = 0
  644. yield* session.prompt({
  645. id: messageID,
  646. sessionID,
  647. text: "Replay pending",
  648. resume: false,
  649. })
  650. const syntheticID = SessionMessage.ID.create()
  651. yield* session.synthetic({ id: syntheticID, sessionID, text: "Replay synthetic", resume: false })
  652. const recorded = yield* db
  653. .select()
  654. .from(EventTable)
  655. .where(eq(EventTable.aggregate_id, sessionID))
  656. .all()
  657. .pipe(Effect.orDie)
  658. yield* bus.remove(sessionID)
  659. yield* db
  660. .delete(SessionPendingTable)
  661. .where(eq(SessionPendingTable.session_id, sessionID))
  662. .run()
  663. .pipe(Effect.orDie)
  664. yield* db
  665. .delete(SessionMessageTable)
  666. .where(eq(SessionMessageTable.session_id, sessionID))
  667. .run()
  668. .pipe(Effect.orDie)
  669. yield* bus.replayAll(
  670. recorded.map((event) => ({
  671. id: event.id,
  672. created: DateTime.makeUnsafe(event.created),
  673. aggregateID: event.aggregate_id,
  674. seq: event.seq,
  675. type: event.type,
  676. data: event.data,
  677. })),
  678. )
  679. expect(yield* admitted(messageID)).toMatchObject({
  680. id: messageID,
  681. type: "user",
  682. data: { text: "Replay pending" },
  683. })
  684. expect(yield* admitted(syntheticID)).toMatchObject({
  685. id: syntheticID,
  686. type: "synthetic",
  687. data: { text: "Replay synthetic" },
  688. })
  689. expect(yield* session.messages({ sessionID })).toEqual([])
  690. expect(wakeCalls).toEqual([])
  691. }),
  692. )
  693. it.effect("rejects reuse of one globally unique message ID across sessions", () =>
  694. Effect.gen(function* () {
  695. yield* setup
  696. const { db } = yield* Database.Service
  697. const session = yield* Session.Service
  698. const other = Session.ID.make("ses_prompt_other")
  699. yield* db
  700. .insert(SessionTable)
  701. .values({
  702. id: other,
  703. project_id: Project.ID.global,
  704. slug: "other",
  705. directory: "/project",
  706. title: "other",
  707. version: "test",
  708. })
  709. .onConflictDoNothing()
  710. .run()
  711. .pipe(Effect.orDie)
  712. yield* session.prompt({ id: messageID, sessionID, text: "Fix the failing tests", resume: false })
  713. const failure = yield* session
  714. .prompt({ id: messageID, sessionID: other, text: "Fix the failing tests", resume: false })
  715. .pipe(Effect.flip)
  716. expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: other, messageID })
  717. }),
  718. )
  719. it.effect("rejects a prompt ID already used by visible Session history", () =>
  720. Effect.gen(function* () {
  721. yield* setup
  722. const session = yield* Session.Service
  723. const { db } = yield* Database.Service
  724. const {
  725. id: _,
  726. type,
  727. ...data
  728. } = encodeMessage({
  729. id: messageID,
  730. type: "synthetic",
  731. text: "Existing history",
  732. time: { created: DateTime.makeUnsafe(0) },
  733. })
  734. yield* db
  735. .insert(SessionMessageTable)
  736. .values({ id: messageID, session_id: sessionID, type, seq: 0, time_created: 0, data })
  737. .run()
  738. .pipe(Effect.orDie)
  739. const failure = yield* session
  740. .prompt({
  741. id: messageID,
  742. sessionID,
  743. text: "Conflicting prompt",
  744. resume: false,
  745. })
  746. .pipe(Effect.flip)
  747. expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
  748. expect(yield* admitted(messageID)).toBeUndefined()
  749. }),
  750. )
  751. it.effect("starts execution by default after recording the prompt", () =>
  752. Effect.gen(function* () {
  753. yield* setup
  754. const session = yield* Session.Service
  755. executionCalls.length = 0
  756. wakeCalls.length = 0
  757. yield* session.prompt({ sessionID, text: "Run by default" })
  758. expect(executionCalls).toEqual([])
  759. expect(wakeCalls).toEqual([sessionID])
  760. }),
  761. )
  762. it.effect("starts execution when resume is explicitly true", () =>
  763. Effect.gen(function* () {
  764. yield* setup
  765. const session = yield* Session.Service
  766. executionCalls.length = 0
  767. wakeCalls.length = 0
  768. yield* session.prompt({
  769. sessionID,
  770. text: "Run explicitly",
  771. resume: true,
  772. })
  773. expect(executionCalls).toEqual([])
  774. expect(wakeCalls).toEqual([sessionID])
  775. }),
  776. )
  777. it.effect("only records the prompt when resume is false", () =>
  778. Effect.gen(function* () {
  779. yield* setup
  780. const session = yield* Session.Service
  781. executionCalls.length = 0
  782. wakeCalls.length = 0
  783. yield* session.prompt({ sessionID, text: "Do not run", resume: false })
  784. expect(executionCalls).toEqual([])
  785. expect(wakeCalls).toEqual([])
  786. }),
  787. )
  788. it.effect("treats prompt metadata as durable retry identity", () =>
  789. Effect.gen(function* () {
  790. yield* setup
  791. const session = yield* Session.Service
  792. const input = {
  793. id: messageID,
  794. sessionID,
  795. text: "Deploy",
  796. metadata: { source: "api" },
  797. resume: false,
  798. }
  799. const first = yield* session.prompt(input)
  800. const retried = yield* session.prompt(input)
  801. const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip)
  802. expect(retried).toEqual(first)
  803. expect(first.data.metadata).toEqual({ source: "api" })
  804. expect(failure._tag).toBe("Session.PromptConflictError")
  805. }),
  806. )
  807. it.effect("durably admits synthetic input before transcript promotion", () =>
  808. Effect.gen(function* () {
  809. yield* setup
  810. const session = yield* Session.Service
  811. const bus = yield* Bus.Service
  812. const { db } = yield* Database.Service
  813. const input = yield* session.synthetic({
  814. id: messageID,
  815. sessionID,
  816. text: "Background work completed",
  817. description: "shell completion",
  818. metadata: { job: "shell" },
  819. resume: false,
  820. })
  821. expect(yield* session.messages({ sessionID })).toEqual([])
  822. expect(yield* admitted(input.id)).toMatchObject({
  823. type: "synthetic",
  824. sessionID,
  825. delivery: "steer",
  826. data: {
  827. text: "Background work completed",
  828. description: "shell completion",
  829. metadata: { job: "shell" },
  830. },
  831. })
  832. yield* SessionPending.promote(db, bus, sessionID, "steer")
  833. expect(yield* session.messages({ sessionID })).toMatchObject([
  834. {
  835. id: messageID,
  836. type: "synthetic",
  837. text: "Background work completed",
  838. description: "shell completion",
  839. metadata: { job: "shell" },
  840. },
  841. ])
  842. }),
  843. )
  844. it.effect("reconciles exact synthetic retries and rejects conflicting reuse", () =>
  845. Effect.gen(function* () {
  846. yield* setup
  847. const session = yield* Session.Service
  848. const bus = yield* Bus.Service
  849. const database = yield* Database.Service
  850. const input = { id: messageID, sessionID, text: "Completed", resume: false }
  851. const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
  852. concurrency: "unbounded",
  853. })
  854. yield* SessionPending.promote(database.db, bus, sessionID, "steer")
  855. const promotedRetry = yield* session.synthetic(input)
  856. const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
  857. expect(entries[1]).toEqual(entries[0])
  858. expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", data: { text: "Completed" } })
  859. expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
  860. expect(yield* admittedCount).toBe(0)
  861. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
  862. }),
  863. )
  864. it.effect("keeps queued input pending until the idle boundary", () =>
  865. Effect.gen(function* () {
  866. yield* setup
  867. const session = yield* Session.Service
  868. const bus = yield* Bus.Service
  869. const { db } = yield* Database.Service
  870. const input = yield* session.synthetic({
  871. sessionID,
  872. text: "Queued completion",
  873. delivery: "queue",
  874. resume: false,
  875. })
  876. expect(input.delivery).toBe("queue")
  877. expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
  878. expect(
  879. yield* SessionPending.promote(db, bus, sessionID, "steer"),
  880. ).toBe(0)
  881. expect(yield* session.messages({ sessionID })).toEqual([])
  882. expect(
  883. yield* SessionPending.promote(db, bus, sessionID, "input"),
  884. ).toBe(1)
  885. expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
  886. expect(yield* session.messages({ sessionID })).toMatchObject([
  887. { id: input.id, type: "synthetic", text: "Queued completion" },
  888. ])
  889. }),
  890. )
  891. it.effect("promotes prompt and synthetic steers in admission order", () =>
  892. Effect.gen(function* () {
  893. yield* setup
  894. const session = yield* Session.Service
  895. const bus = yield* Bus.Service
  896. const { db } = yield* Database.Service
  897. yield* session.prompt({
  898. sessionID,
  899. text: "First prompt",
  900. resume: false,
  901. })
  902. yield* session.synthetic({ sessionID, text: "Background completion", resume: false })
  903. yield* session.prompt({
  904. sessionID,
  905. text: "Second prompt",
  906. resume: false,
  907. })
  908. yield* SessionPending.promote(db, bus, sessionID, "steer")
  909. expect(
  910. (yield* session.messages({ sessionID, order: "asc" })).map((message) =>
  911. message.type === "user" || message.type === "synthetic" ? message.text : message.type,
  912. ),
  913. ).toEqual(["First prompt", "Background completion", "Second prompt"])
  914. }),
  915. )
  916. })
  917. describe("Session.pending", () => {
  918. it.effect("fails for an unknown session", () =>
  919. Effect.gen(function* () {
  920. const session = yield* Session.Service
  921. expect(yield* session.pending(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
  922. _tag: "Session.NotFoundError",
  923. })
  924. }),
  925. )
  926. it.effect("lists admitted work in admission order until promotion", () =>
  927. Effect.gen(function* () {
  928. yield* setup
  929. const session = yield* Session.Service
  930. const bus = yield* Bus.Service
  931. const { db } = yield* Database.Service
  932. const first = yield* session.prompt({ sessionID, text: "First steer", resume: false })
  933. const queued = yield* session.synthetic({
  934. sessionID,
  935. text: "Queued completion",
  936. delivery: "queue",
  937. resume: false,
  938. })
  939. const second = yield* session.prompt({ sessionID, text: "Second steer", resume: false })
  940. expect(yield* session.pending(sessionID)).toMatchObject([
  941. { id: first.id, type: "user", delivery: "steer" },
  942. { id: queued.id, type: "synthetic", delivery: "queue" },
  943. { id: second.id, type: "user", delivery: "steer" },
  944. ])
  945. expect(
  946. yield* SessionPending.promote(db, bus, sessionID, "input"),
  947. ).toBe(2)
  948. expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
  949. expect(
  950. yield* SessionPending.promote(db, bus, sessionID, "input"),
  951. ).toBe(1)
  952. expect(yield* session.pending(sessionID)).toEqual([])
  953. }),
  954. )
  955. it.effect("lists an unhandled compaction barrier until it settles", () =>
  956. Effect.gen(function* () {
  957. yield* setup
  958. const session = yield* Session.Service
  959. const { db } = yield* Database.Service
  960. const barrier = yield* session.compact({ sessionID })
  961. expect(yield* SessionPending.has(db, sessionID, "any")).toBe(true)
  962. expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
  963. expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
  964. yield* SessionPending.settleCompaction(db, { sessionID })
  965. expect(yield* SessionPending.has(db, sessionID, "any")).toBe(false)
  966. expect(yield* session.pending(sessionID)).toEqual([])
  967. }),
  968. )
  969. it.effect("cancels pending input and allows its ID to be admitted again", () =>
  970. Effect.gen(function* () {
  971. yield* setup
  972. const session = yield* Session.Service
  973. const inputID = SessionMessage.ID.make("msg_cancelled_queue")
  974. yield* session.prompt({
  975. id: inputID,
  976. sessionID,
  977. text: "Queue this",
  978. delivery: "queue",
  979. resume: false,
  980. })
  981. yield* session.cancelPending({ sessionID, inputID })
  982. expect(yield* session.pending(sessionID)).toEqual([])
  983. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
  984. expect(
  985. yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
  986. ).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
  987. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
  988. const retried = yield* session.prompt({
  989. id: inputID,
  990. sessionID,
  991. text: "Queue this",
  992. delivery: "queue",
  993. resume: false,
  994. })
  995. expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
  996. }),
  997. )
  998. it.effect("moves pending input between steer and queue delivery", () =>
  999. Effect.gen(function* () {
  1000. yield* setup
  1001. const session = yield* Session.Service
  1002. const queued = yield* session.synthetic({
  1003. sessionID,
  1004. text: "Steer this",
  1005. delivery: "queue",
  1006. resume: false,
  1007. })
  1008. const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
  1009. wakeCalls.length = 0
  1010. yield* session.steerPending({ sessionID, inputID: queued.id })
  1011. expect(yield* session.pending(sessionID)).toMatchObject([
  1012. { id: queued.id, delivery: "steer" },
  1013. { id: alreadySteered.id, delivery: "steer" },
  1014. ])
  1015. expect(wakeCalls).toEqual([sessionID])
  1016. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
  1017. wakeCalls.length = 0
  1018. yield* session.queuePending({ sessionID, inputID: queued.id })
  1019. expect(yield* session.pending(sessionID)).toMatchObject([
  1020. { id: queued.id, delivery: "queue" },
  1021. { id: alreadySteered.id, delivery: "steer" },
  1022. ])
  1023. expect(wakeCalls).toEqual([])
  1024. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
  1025. expect(
  1026. yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
  1027. ).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
  1028. yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
  1029. expect(wakeCalls).toEqual([])
  1030. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
  1031. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
  1032. }),
  1033. )
  1034. })