session-prompt.test.ts 32 KB

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