session-prompt.test.ts 33 KB

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