1
0

session-prompt.test.ts 39 KB

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