session-prompt.test.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089
  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. })