session-prompt.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002
  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 { AgentV2 } 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 { EventV2 } from "@opencode-ai/core/event"
  13. import { EventTable } from "@opencode-ai/core/event/sql"
  14. import { SessionEvent } from "@opencode-ai/core/session/event"
  15. import { ModelV2 } from "@opencode-ai/core/model"
  16. import { ProviderV2 } 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 { SessionV2 } 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: SessionV2.ID[] = []
  32. const interruptCalls: SessionV2.ID[] = []
  33. const wakeCalls: SessionV2.ID[] = []
  34. const activeSessions = new Set<SessionV2.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, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
  66. [
  67. [SessionExecution.node, execution],
  68. [LocationServiceMap.node, locations],
  69. ],
  70. ),
  71. )
  72. const sessionID = SessionV2.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: AgentV2.ID.make("build"),
  130. model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.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("SessionV2.prompt", () => {
  138. it.effect("exposes the execution registry", () =>
  139. Effect.gen(function* () {
  140. activeSessions.add(sessionID)
  141. expect(Array.from(yield* (yield* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.Service
  168. interruptCalls.length = 0
  169. yield* session.interrupt(SessionV2.ID.make("ses_missing"))
  170. expect(interruptCalls).toEqual([SessionV2.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* SessionV2.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* SessionV2.Service
  197. const events = yield* EventV2.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.promoteSteers(db, events, sessionID)
  205. const stale = SessionMessage.ID.make("msg_stale_assistant")
  206. yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
  207. yield* events.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* SessionV2.Service
  226. const events = yield* EventV2.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.promoteSteers(db, events, sessionID)
  234. yield* events.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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.Service
  399. const events = yield* EventV2.Service
  400. const { db } = yield* Database.Service
  401. const publicEvents = (input: { sessionID: SessionV2.ID; after?: number }) =>
  402. session
  403. .log({ ...input, follow: true })
  404. .pipe(Stream.filter((item): item is SessionEvent.DurableEvent => !EventV2.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.promoteSteers(db, events, sessionID)
  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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.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(EventV2.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* SessionV2.Service
  554. const events = yield* EventV2.Service
  555. yield* session.prompt({
  556. id: messageID,
  557. sessionID,
  558. text: "Promote once",
  559. resume: false,
  560. })
  561. yield* Effect.all(
  562. [SessionPending.promoteSteers(db, events, sessionID), SessionPending.promoteSteers(db, events, sessionID)],
  563. { concurrency: "unbounded" },
  564. )
  565. expect(yield* eventCount(EventV2.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* SessionV2.Service
  577. const events = yield* EventV2.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* events.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* events.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* SessionV2.Service
  633. const other = SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.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* SessionV2.Service
  746. const events = yield* EventV2.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.promoteSteers(db, events, sessionID)
  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* SessionV2.Service
  783. const events = yield* EventV2.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.promoteSteers(database.db, events, sessionID)
  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(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
  797. }),
  798. )
  799. it.effect("keeps synthetic queue input pending until the queue boundary", () =>
  800. Effect.gen(function* () {
  801. yield* setup
  802. const session = yield* SessionV2.Service
  803. const events = yield* EventV2.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.promoteSteers(db, events, sessionID)).toBe(0)
  813. expect(yield* session.messages({ sessionID })).toEqual([])
  814. expect(yield* SessionPending.promoteNextQueued(db, events, sessionID)).toBe(true)
  815. expect(yield* session.messages({ sessionID })).toMatchObject([
  816. { id: input.id, type: "synthetic", text: "Queued completion" },
  817. ])
  818. }),
  819. )
  820. it.effect("promotes prompt and synthetic steers in admission order", () =>
  821. Effect.gen(function* () {
  822. yield* setup
  823. const session = yield* SessionV2.Service
  824. const events = yield* EventV2.Service
  825. const { db } = yield* Database.Service
  826. yield* session.prompt({
  827. sessionID,
  828. text: "First prompt",
  829. resume: false,
  830. })
  831. yield* session.synthetic({ sessionID, text: "Background completion", resume: false })
  832. yield* session.prompt({
  833. sessionID,
  834. text: "Second prompt",
  835. resume: false,
  836. })
  837. yield* SessionPending.promoteSteers(db, events, sessionID)
  838. expect(
  839. (yield* session.messages({ sessionID, order: "asc" })).map((message) =>
  840. message.type === "user" || message.type === "synthetic" ? message.text : message.type,
  841. ),
  842. ).toEqual(["First prompt", "Background completion", "Second prompt"])
  843. }),
  844. )
  845. })
  846. describe("SessionV2.pending", () => {
  847. it.effect("fails for an unknown session", () =>
  848. Effect.gen(function* () {
  849. const session = yield* SessionV2.Service
  850. expect(yield* session.pending(SessionV2.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
  851. _tag: "Session.NotFoundError",
  852. })
  853. }),
  854. )
  855. it.effect("lists admitted work in admission order until promotion", () =>
  856. Effect.gen(function* () {
  857. yield* setup
  858. const session = yield* SessionV2.Service
  859. const events = yield* EventV2.Service
  860. const { db } = yield* Database.Service
  861. const first = yield* session.prompt({ sessionID, text: "First steer", resume: false })
  862. const queued = yield* session.synthetic({
  863. sessionID,
  864. text: "Queued completion",
  865. delivery: "queue",
  866. resume: false,
  867. })
  868. const second = yield* session.prompt({ sessionID, text: "Second steer", resume: false })
  869. expect(yield* session.pending(sessionID)).toMatchObject([
  870. { id: first.id, type: "user", delivery: "steer" },
  871. { id: queued.id, type: "synthetic", delivery: "queue" },
  872. { id: second.id, type: "user", delivery: "steer" },
  873. ])
  874. yield* SessionPending.promoteSteers(db, events, sessionID)
  875. expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
  876. yield* SessionPending.promoteNextQueued(db, events, sessionID)
  877. expect(yield* session.pending(sessionID)).toEqual([])
  878. }),
  879. )
  880. it.effect("lists an unhandled compaction barrier until it settles", () =>
  881. Effect.gen(function* () {
  882. yield* setup
  883. const session = yield* SessionV2.Service
  884. const { db } = yield* Database.Service
  885. const barrier = yield* session.compact({ sessionID })
  886. expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
  887. yield* SessionPending.settleCompaction(db, { sessionID })
  888. expect(yield* session.pending(sessionID)).toEqual([])
  889. }),
  890. )
  891. })