session-prompt.test.ts 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155
  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.delete(EventTable).where(eq(EventTable.aggregate_id, sessionID)).run().pipe(Effect.orDie)
  511. const retried = yield* session.prompt(input)
  512. expect(retried).toMatchObject({ id: first.id, type: "user", data: { text: first.data.text } })
  513. expect(yield* session.messages({ sessionID })).toMatchObject([
  514. { id: messageID, type: "user", text: "Fix the failing tests" },
  515. ])
  516. }),
  517. )
  518. it.effect("ignores delivery when retrying a promoted message", () =>
  519. Effect.gen(function* () {
  520. yield* setup
  521. const session = yield* Session.Service
  522. const bus = yield* Bus.Service
  523. const { db } = yield* Database.Service
  524. const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
  525. yield* session.prompt(input)
  526. yield* SessionPending.promote(db, bus, sessionID, "steer")
  527. const retried = yield* session.prompt({ ...input, delivery: "queue" })
  528. expect(retried).toMatchObject({ id: messageID, type: "user", data: { text: input.text } })
  529. expect(yield* admitted(messageID)).toBeUndefined()
  530. }),
  531. )
  532. it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
  533. Effect.gen(function* () {
  534. yield* setup
  535. const session = yield* Session.Service
  536. const input = {
  537. sessionID,
  538. id: messageID,
  539. text: "Recover committed prompt",
  540. resume: false,
  541. }
  542. const first = yield* session.prompt(input)
  543. wakeCalls.length = 0
  544. const retried = yield* session.prompt({ ...input, resume: true })
  545. expect(retried).toEqual(first)
  546. expect(wakeCalls).toEqual([sessionID])
  547. }),
  548. )
  549. it.effect("rejects reuse of one ID with a different prompt", () =>
  550. Effect.gen(function* () {
  551. yield* setup
  552. const session = yield* Session.Service
  553. yield* session.prompt({
  554. sessionID,
  555. id: messageID,
  556. text: "Fix the failing tests",
  557. })
  558. const failure = yield* session
  559. .prompt({
  560. sessionID,
  561. id: messageID,
  562. text: "Delete the failing tests",
  563. resume: false,
  564. })
  565. .pipe(Effect.flip)
  566. expect(failure._tag).toBe("Session.PromptConflictError")
  567. expect(yield* session.messages({ sessionID })).toHaveLength(0)
  568. expect(yield* admittedCount).toBe(1)
  569. }),
  570. )
  571. it.effect("rejects reuse of one ID with a different delivery mode", () =>
  572. Effect.gen(function* () {
  573. yield* setup
  574. const session = yield* Session.Service
  575. yield* session.prompt({
  576. id: messageID,
  577. sessionID,
  578. text: "Fix the failing tests",
  579. resume: false,
  580. })
  581. const failure = yield* session
  582. .prompt({
  583. id: messageID,
  584. sessionID,
  585. text: "Fix the failing tests",
  586. delivery: "queue",
  587. resume: false,
  588. })
  589. .pipe(Effect.flip)
  590. expect(failure._tag).toBe("Session.PromptConflictError")
  591. }),
  592. )
  593. it.effect("returns one recorded message to concurrent exact retries", () =>
  594. Effect.gen(function* () {
  595. yield* setup
  596. const session = yield* Session.Service
  597. const input = {
  598. sessionID,
  599. id: messageID,
  600. text: "Fix the failing tests",
  601. resume: false,
  602. }
  603. const messages = yield* Effect.all([session.prompt(input), session.prompt(input)], { concurrency: "unbounded" })
  604. expect(messages[1]).toEqual(messages[0])
  605. expect(yield* session.messages({ sessionID })).toEqual([])
  606. expect(yield* admittedCount).toBe(1)
  607. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
  608. }),
  609. )
  610. it.effect("promotes one message once under concurrent promotion attempts", () =>
  611. Effect.gen(function* () {
  612. yield* setup
  613. const { db } = yield* Database.Service
  614. const session = yield* Session.Service
  615. const bus = yield* Bus.Service
  616. yield* session.prompt({
  617. id: messageID,
  618. sessionID,
  619. text: "Promote once",
  620. resume: false,
  621. })
  622. yield* Effect.all(
  623. [SessionPending.promote(db, bus, sessionID, "steer"), SessionPending.promote(db, bus, sessionID, "steer")],
  624. { concurrency: "unbounded" },
  625. )
  626. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
  627. expect(yield* admitted(messageID)).toBeUndefined()
  628. expect(yield* session.messages({ sessionID })).toMatchObject([
  629. { id: messageID, type: "user", text: "Promote once" },
  630. ])
  631. }),
  632. )
  633. it.effect("reprojects pending inbox input without scheduling execution", () =>
  634. Effect.gen(function* () {
  635. yield* setup
  636. const { db } = yield* Database.Service
  637. const session = yield* Session.Service
  638. const bus = yield* Bus.Service
  639. wakeCalls.length = 0
  640. yield* session.prompt({
  641. id: messageID,
  642. sessionID,
  643. text: "Replay pending",
  644. resume: false,
  645. })
  646. const syntheticID = SessionMessage.ID.create()
  647. yield* session.synthetic({ id: syntheticID, sessionID, text: "Replay synthetic", resume: false })
  648. const recorded = yield* db
  649. .select()
  650. .from(EventTable)
  651. .where(eq(EventTable.aggregate_id, sessionID))
  652. .all()
  653. .pipe(Effect.orDie)
  654. yield* bus.remove(sessionID)
  655. yield* db
  656. .delete(SessionPendingTable)
  657. .where(eq(SessionPendingTable.session_id, sessionID))
  658. .run()
  659. .pipe(Effect.orDie)
  660. yield* db
  661. .delete(SessionMessageTable)
  662. .where(eq(SessionMessageTable.session_id, sessionID))
  663. .run()
  664. .pipe(Effect.orDie)
  665. yield* bus.replayAll(
  666. recorded.map((event) => ({
  667. id: event.id,
  668. created: DateTime.makeUnsafe(event.created),
  669. aggregateID: event.aggregate_id,
  670. seq: event.seq,
  671. type: event.type,
  672. data: event.data,
  673. })),
  674. )
  675. expect(yield* admitted(messageID)).toMatchObject({
  676. id: messageID,
  677. type: "user",
  678. data: { text: "Replay pending" },
  679. })
  680. expect(yield* admitted(syntheticID)).toMatchObject({
  681. id: syntheticID,
  682. type: "synthetic",
  683. data: { text: "Replay synthetic" },
  684. })
  685. expect(yield* session.messages({ sessionID })).toEqual([])
  686. expect(wakeCalls).toEqual([])
  687. }),
  688. )
  689. it.effect("rejects reuse of one globally unique message ID across sessions", () =>
  690. Effect.gen(function* () {
  691. yield* setup
  692. const { db } = yield* Database.Service
  693. const session = yield* Session.Service
  694. const other = Session.ID.make("ses_prompt_other")
  695. yield* db
  696. .insert(SessionTable)
  697. .values({
  698. id: other,
  699. project_id: Project.ID.global,
  700. slug: "other",
  701. directory: "/project",
  702. title: "other",
  703. version: "test",
  704. })
  705. .onConflictDoNothing()
  706. .run()
  707. .pipe(Effect.orDie)
  708. yield* session.prompt({ id: messageID, sessionID, text: "Fix the failing tests", resume: false })
  709. const failure = yield* session
  710. .prompt({ id: messageID, sessionID: other, text: "Fix the failing tests", resume: false })
  711. .pipe(Effect.flip)
  712. expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: other, messageID })
  713. }),
  714. )
  715. it.effect("rejects a prompt ID already used by visible Session history", () =>
  716. Effect.gen(function* () {
  717. yield* setup
  718. const session = yield* Session.Service
  719. const { db } = yield* Database.Service
  720. const {
  721. id: _,
  722. type,
  723. ...data
  724. } = encodeMessage({
  725. id: messageID,
  726. type: "synthetic",
  727. text: "Existing history",
  728. time: { created: DateTime.makeUnsafe(0) },
  729. })
  730. yield* db
  731. .insert(SessionMessageTable)
  732. .values({ id: messageID, session_id: sessionID, type, seq: 0, time_created: 0, data })
  733. .run()
  734. .pipe(Effect.orDie)
  735. const failure = yield* session
  736. .prompt({
  737. id: messageID,
  738. sessionID,
  739. text: "Conflicting prompt",
  740. resume: false,
  741. })
  742. .pipe(Effect.flip)
  743. expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
  744. expect(yield* admitted(messageID)).toBeUndefined()
  745. }),
  746. )
  747. it.effect("starts execution by default after recording the prompt", () =>
  748. Effect.gen(function* () {
  749. yield* setup
  750. const session = yield* Session.Service
  751. executionCalls.length = 0
  752. wakeCalls.length = 0
  753. yield* session.prompt({ sessionID, text: "Run by default" })
  754. expect(executionCalls).toEqual([])
  755. expect(wakeCalls).toEqual([sessionID])
  756. }),
  757. )
  758. it.effect("starts execution when resume is explicitly true", () =>
  759. Effect.gen(function* () {
  760. yield* setup
  761. const session = yield* Session.Service
  762. executionCalls.length = 0
  763. wakeCalls.length = 0
  764. yield* session.prompt({
  765. sessionID,
  766. text: "Run explicitly",
  767. resume: true,
  768. })
  769. expect(executionCalls).toEqual([])
  770. expect(wakeCalls).toEqual([sessionID])
  771. }),
  772. )
  773. it.effect("only records the prompt when resume is false", () =>
  774. Effect.gen(function* () {
  775. yield* setup
  776. const session = yield* Session.Service
  777. executionCalls.length = 0
  778. wakeCalls.length = 0
  779. yield* session.prompt({ sessionID, text: "Do not run", resume: false })
  780. expect(executionCalls).toEqual([])
  781. expect(wakeCalls).toEqual([])
  782. }),
  783. )
  784. it.effect("treats prompt metadata as durable retry identity", () =>
  785. Effect.gen(function* () {
  786. yield* setup
  787. const session = yield* Session.Service
  788. const input = {
  789. id: messageID,
  790. sessionID,
  791. text: "Deploy",
  792. metadata: { source: "api" },
  793. resume: false,
  794. }
  795. const first = yield* session.prompt(input)
  796. const retried = yield* session.prompt(input)
  797. const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip)
  798. expect(retried).toEqual(first)
  799. expect(first.data.metadata).toEqual({ source: "api" })
  800. expect(failure._tag).toBe("Session.PromptConflictError")
  801. }),
  802. )
  803. it.effect("durably admits synthetic input before transcript promotion", () =>
  804. Effect.gen(function* () {
  805. yield* setup
  806. const session = yield* Session.Service
  807. const bus = yield* Bus.Service
  808. const { db } = yield* Database.Service
  809. const input = yield* session.synthetic({
  810. id: messageID,
  811. sessionID,
  812. text: "Background work completed",
  813. description: "shell completion",
  814. metadata: { job: "shell" },
  815. resume: false,
  816. })
  817. expect(yield* session.messages({ sessionID })).toEqual([])
  818. expect(yield* admitted(input.id)).toMatchObject({
  819. type: "synthetic",
  820. sessionID,
  821. delivery: "steer",
  822. data: {
  823. text: "Background work completed",
  824. description: "shell completion",
  825. metadata: { job: "shell" },
  826. },
  827. })
  828. yield* SessionPending.promote(db, bus, sessionID, "steer")
  829. expect(yield* session.messages({ sessionID })).toMatchObject([
  830. {
  831. id: messageID,
  832. type: "synthetic",
  833. text: "Background work completed",
  834. description: "shell completion",
  835. metadata: { job: "shell" },
  836. },
  837. ])
  838. }),
  839. )
  840. it.effect("reconciles exact synthetic retries and rejects conflicting reuse", () =>
  841. Effect.gen(function* () {
  842. yield* setup
  843. const session = yield* Session.Service
  844. const bus = yield* Bus.Service
  845. const database = yield* Database.Service
  846. const input = { id: messageID, sessionID, text: "Completed", resume: false }
  847. const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
  848. concurrency: "unbounded",
  849. })
  850. yield* SessionPending.promote(database.db, bus, sessionID, "steer")
  851. const promotedRetry = yield* session.synthetic(input)
  852. const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
  853. expect(entries[1]).toEqual(entries[0])
  854. expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", data: { text: "Completed" } })
  855. expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
  856. expect(yield* admittedCount).toBe(0)
  857. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
  858. }),
  859. )
  860. it.effect("keeps queued input pending until the idle boundary", () =>
  861. Effect.gen(function* () {
  862. yield* setup
  863. const session = yield* Session.Service
  864. const bus = yield* Bus.Service
  865. const { db } = yield* Database.Service
  866. const input = yield* session.synthetic({
  867. sessionID,
  868. text: "Queued completion",
  869. delivery: "queue",
  870. resume: false,
  871. })
  872. expect(input.delivery).toBe("queue")
  873. expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
  874. expect(yield* SessionPending.promote(db, bus, sessionID, "steer")).toBe(0)
  875. expect(yield* session.messages({ sessionID })).toEqual([])
  876. expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
  877. expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
  878. expect(yield* session.messages({ sessionID })).toMatchObject([
  879. { id: input.id, type: "synthetic", text: "Queued completion" },
  880. ])
  881. }),
  882. )
  883. it.effect("promotes prompt and synthetic steers in admission order", () =>
  884. Effect.gen(function* () {
  885. yield* setup
  886. const session = yield* Session.Service
  887. const bus = yield* Bus.Service
  888. const { db } = yield* Database.Service
  889. yield* session.prompt({
  890. sessionID,
  891. text: "First prompt",
  892. resume: false,
  893. })
  894. yield* session.synthetic({ sessionID, text: "Background completion", resume: false })
  895. yield* session.prompt({
  896. sessionID,
  897. text: "Second prompt",
  898. resume: false,
  899. })
  900. yield* SessionPending.promote(db, bus, sessionID, "steer")
  901. expect(
  902. (yield* session.messages({ sessionID, order: "asc" })).map((message) =>
  903. message.type === "user" || message.type === "synthetic" ? message.text : message.type,
  904. ),
  905. ).toEqual(["First prompt", "Background completion", "Second prompt"])
  906. }),
  907. )
  908. })
  909. describe("Session.pending", () => {
  910. it.effect("fails for an unknown session", () =>
  911. Effect.gen(function* () {
  912. const session = yield* Session.Service
  913. expect(yield* session.pending(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
  914. _tag: "Session.NotFoundError",
  915. })
  916. }),
  917. )
  918. it.effect("lists admitted work in admission order until promotion", () =>
  919. Effect.gen(function* () {
  920. yield* setup
  921. const session = yield* Session.Service
  922. const bus = yield* Bus.Service
  923. const { db } = yield* Database.Service
  924. const first = yield* session.prompt({ sessionID, text: "First steer", resume: false })
  925. const queued = yield* session.synthetic({
  926. sessionID,
  927. text: "Queued completion",
  928. delivery: "queue",
  929. resume: false,
  930. })
  931. const second = yield* session.prompt({ sessionID, text: "Second steer", resume: false })
  932. expect(yield* session.pending(sessionID)).toMatchObject([
  933. { id: first.id, type: "user", delivery: "steer" },
  934. { id: queued.id, type: "synthetic", delivery: "queue" },
  935. { id: second.id, type: "user", delivery: "steer" },
  936. ])
  937. expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(2)
  938. expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
  939. expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
  940. expect(yield* session.pending(sessionID)).toEqual([])
  941. }),
  942. )
  943. it.effect("lists an unhandled compaction barrier until it settles", () =>
  944. Effect.gen(function* () {
  945. yield* setup
  946. const session = yield* Session.Service
  947. const { db } = yield* Database.Service
  948. const barrier = yield* session.compact({ sessionID })
  949. expect(yield* SessionPending.has(db, sessionID, "any")).toBe(true)
  950. expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
  951. expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
  952. yield* SessionPending.settleCompaction(db, { sessionID })
  953. expect(yield* SessionPending.has(db, sessionID, "any")).toBe(false)
  954. expect(yield* session.pending(sessionID)).toEqual([])
  955. }),
  956. )
  957. it.effect("cancels pending input and allows its ID to be admitted again", () =>
  958. Effect.gen(function* () {
  959. yield* setup
  960. const session = yield* Session.Service
  961. const inputID = SessionMessage.ID.make("msg_cancelled_queue")
  962. yield* session.prompt({
  963. id: inputID,
  964. sessionID,
  965. text: "Queue this",
  966. delivery: "queue",
  967. resume: false,
  968. })
  969. yield* session.cancelPending({ sessionID, inputID })
  970. expect(yield* session.pending(sessionID)).toEqual([])
  971. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
  972. expect(yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip)).toMatchObject({
  973. _tag: "Session.PendingInputConflictError",
  974. sessionID,
  975. inputID,
  976. })
  977. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
  978. const retried = yield* session.prompt({
  979. id: inputID,
  980. sessionID,
  981. text: "Queue this",
  982. delivery: "queue",
  983. resume: false,
  984. })
  985. expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
  986. }),
  987. )
  988. it.effect("moves pending input between steer and queue delivery", () =>
  989. Effect.gen(function* () {
  990. yield* setup
  991. const session = yield* Session.Service
  992. const queued = yield* session.synthetic({
  993. sessionID,
  994. text: "Steer this",
  995. delivery: "queue",
  996. resume: false,
  997. })
  998. const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
  999. wakeCalls.length = 0
  1000. yield* session.steerPending({ sessionID, inputID: queued.id })
  1001. expect(yield* session.pending(sessionID)).toMatchObject([
  1002. { id: queued.id, delivery: "steer" },
  1003. { id: alreadySteered.id, delivery: "steer" },
  1004. ])
  1005. expect(wakeCalls).toEqual([sessionID])
  1006. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
  1007. wakeCalls.length = 0
  1008. yield* session.queuePending({ sessionID, inputID: queued.id })
  1009. expect(yield* session.pending(sessionID)).toMatchObject([
  1010. { id: queued.id, delivery: "queue" },
  1011. { id: alreadySteered.id, delivery: "steer" },
  1012. ])
  1013. expect(wakeCalls).toEqual([])
  1014. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
  1015. expect(yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({
  1016. _tag: "Session.PendingInputConflictError",
  1017. sessionID,
  1018. inputID: alreadySteered.id,
  1019. })
  1020. yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
  1021. expect(wakeCalls).toEqual([])
  1022. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
  1023. expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
  1024. }),
  1025. )
  1026. })