session-prompt.test.ts 34 KB

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