session-projector.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. import { describe, expect } from "bun:test"
  2. import { DateTime, Effect, Layer, Schema } from "effect"
  3. import { asc, eq } from "drizzle-orm"
  4. import { Database } from "@opencode-ai/core/database/database"
  5. import { EventV2 } from "@opencode-ai/core/event"
  6. import { EventTable } from "@opencode-ai/core/event/sql"
  7. import { ModelV2 } from "@opencode-ai/core/model"
  8. import { Project } from "@opencode-ai/core/project"
  9. import { ProjectTable } from "@opencode-ai/core/project/sql"
  10. import { ProviderV2 } from "@opencode-ai/core/provider"
  11. import { AbsolutePath } from "@opencode-ai/core/schema"
  12. import { SessionV2 } from "@opencode-ai/core/session"
  13. import { SessionEvent } from "@opencode-ai/core/session/event"
  14. import { SessionMessage } from "@opencode-ai/core/session/message"
  15. import { Prompt } from "@opencode-ai/core/session/prompt"
  16. import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
  17. import { SessionProjector } from "@opencode-ai/core/session/projector"
  18. import { SessionExecution } from "@opencode-ai/core/session/execution"
  19. import { SessionInput } from "@opencode-ai/core/session/input"
  20. import { SessionStore } from "@opencode-ai/core/session/store"
  21. import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
  22. import { testEffect } from "./lib/effect"
  23. const database = Database.layerFromPath(":memory:")
  24. const events = EventV2.layer.pipe(Layer.provide(database))
  25. const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
  26. const it = testEffect(Layer.mergeAll(database, events, projector))
  27. const sessionID = SessionV2.ID.make("ses_projector_test")
  28. const created = DateTime.makeUnsafe(0)
  29. const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
  30. const encodeMessage = Schema.encodeSync(SessionMessage.Message)
  31. const assistantRow = (
  32. id: SessionMessage.ID,
  33. seq: number,
  34. time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
  35. ) => {
  36. const {
  37. id: _,
  38. type,
  39. ...data
  40. } = encodeMessage(new SessionMessage.Assistant({ id, type: "assistant", agent: "build", model, content: [], time }))
  41. return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
  42. }
  43. describe("SessionProjector", () => {
  44. it.effect("orders projected messages and context by durable aggregate sequence", () =>
  45. Effect.gen(function* () {
  46. const { db } = yield* Database.Service
  47. yield* db
  48. .insert(ProjectTable)
  49. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  50. .run()
  51. .pipe(Effect.orDie)
  52. yield* db
  53. .insert(SessionTable)
  54. .values({
  55. id: sessionID,
  56. project_id: Project.ID.global,
  57. slug: "test",
  58. directory: "/project",
  59. title: "test",
  60. version: "test",
  61. })
  62. .run()
  63. .pipe(Effect.orDie)
  64. const events = yield* EventV2.Service
  65. yield* events.publish(
  66. SessionEvent.Prompted,
  67. {
  68. sessionID,
  69. messageID: SessionMessage.ID.make("msg_first"),
  70. timestamp: created,
  71. prompt: new Prompt({ text: "first" }),
  72. delivery: "steer",
  73. },
  74. { id: EventV2.ID.make("evt_z") },
  75. )
  76. yield* events.publish(
  77. SessionEvent.Prompted,
  78. {
  79. sessionID,
  80. messageID: SessionMessage.ID.make("msg_second"),
  81. timestamp: created,
  82. prompt: new Prompt({ text: "second" }),
  83. delivery: "steer",
  84. },
  85. { id: EventV2.ID.make("evt_a") },
  86. )
  87. const sessions = yield* SessionV2.Service
  88. const firstPage = yield* sessions.messages({ sessionID, limit: 1, order: "asc" })
  89. expect(firstPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["first"])
  90. const secondPage = yield* sessions.messages({
  91. sessionID,
  92. limit: 1,
  93. order: "asc",
  94. cursor: { id: firstPage[0]!.id, direction: "next" },
  95. })
  96. expect(secondPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["second"])
  97. expect(
  98. (yield* sessions.messages({
  99. sessionID,
  100. limit: 1,
  101. order: "asc",
  102. cursor: { id: secondPage[0]!.id, direction: "previous" },
  103. })).map((message) => (message.type === "user" ? message.text : message.type)),
  104. ).toEqual(["first"])
  105. expect(
  106. (yield* sessions.context(sessionID)).map((message) => (message.type === "user" ? message.text : message.type)),
  107. ).toEqual(["first", "second"])
  108. }).pipe(
  109. Effect.provide(
  110. SessionV2.layer.pipe(
  111. Layer.provide(events),
  112. Layer.provide(database),
  113. Layer.provide(Project.defaultLayer),
  114. Layer.provide(SessionStore.layer.pipe(Layer.provide(database))),
  115. Layer.provide(SessionExecution.noopLayer),
  116. ),
  117. ),
  118. ),
  119. )
  120. it.effect("marks an admitted lifecycle row promoted with the PromptPromoted event sequence", () =>
  121. Effect.gen(function* () {
  122. const { db } = yield* Database.Service
  123. yield* db
  124. .insert(ProjectTable)
  125. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  126. .run()
  127. .pipe(Effect.orDie)
  128. yield* db
  129. .insert(SessionTable)
  130. .values({
  131. id: sessionID,
  132. project_id: Project.ID.global,
  133. slug: "test",
  134. directory: "/project",
  135. title: "test",
  136. version: "test",
  137. })
  138. .run()
  139. .pipe(Effect.orDie)
  140. const events = yield* EventV2.Service
  141. const id = SessionMessage.ID.make("msg_admitted")
  142. yield* SessionInput.admit(db, events, {
  143. id,
  144. sessionID,
  145. prompt: new Prompt({ text: "promote me" }),
  146. delivery: "steer",
  147. })
  148. const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, {
  149. sessionID,
  150. timestamp: created,
  151. messageID: id,
  152. prompt: new Prompt({ text: "promote me" }),
  153. timeCreated: created,
  154. })
  155. expect(
  156. yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
  157. ).toMatchObject({ promoted_seq: event.seq })
  158. }),
  159. )
  160. it.effect("projects durable context messages supported by the updater", () =>
  161. Effect.gen(function* () {
  162. const { db } = yield* Database.Service
  163. yield* db
  164. .insert(ProjectTable)
  165. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  166. .run()
  167. .pipe(Effect.orDie)
  168. yield* db
  169. .insert(SessionTable)
  170. .values({
  171. id: sessionID,
  172. project_id: Project.ID.global,
  173. slug: "test",
  174. directory: "/project",
  175. title: "test",
  176. version: "test",
  177. })
  178. .run()
  179. .pipe(Effect.orDie)
  180. const events = yield* EventV2.Service
  181. yield* events.publish(SessionEvent.AgentSwitched, {
  182. sessionID,
  183. messageID: SessionMessage.ID.create(),
  184. timestamp: created,
  185. agent: "build",
  186. })
  187. yield* events.publish(SessionEvent.ModelSwitched, {
  188. sessionID,
  189. messageID: SessionMessage.ID.create(),
  190. timestamp: created,
  191. model,
  192. })
  193. yield* events.publish(SessionEvent.Synthetic, {
  194. sessionID,
  195. messageID: SessionMessage.ID.create(),
  196. timestamp: created,
  197. text: "synthetic context",
  198. })
  199. yield* events.publish(SessionEvent.Shell.Started, {
  200. sessionID,
  201. messageID: SessionMessage.ID.create(),
  202. timestamp: created,
  203. callID: "shell-1",
  204. command: "pwd",
  205. })
  206. yield* events.publish(SessionEvent.Shell.Ended, {
  207. sessionID,
  208. timestamp: DateTime.makeUnsafe(1),
  209. callID: "shell-1",
  210. output: "/project",
  211. })
  212. const compactionID = SessionMessage.ID.create()
  213. yield* events.publish(SessionEvent.Compaction.Started, {
  214. sessionID,
  215. messageID: compactionID,
  216. timestamp: created,
  217. reason: "manual",
  218. })
  219. yield* events.publish(SessionEvent.Compaction.Delta, {
  220. sessionID,
  221. messageID: compactionID,
  222. timestamp: created,
  223. text: "partial",
  224. })
  225. expect(
  226. yield* db
  227. .select({ id: EventTable.id })
  228. .from(EventTable)
  229. .where(eq(EventTable.type, SessionEvent.Compaction.Delta.type))
  230. .all()
  231. .pipe(Effect.orDie),
  232. ).toEqual([])
  233. expect(
  234. yield* db
  235. .select({ id: SessionMessageTable.id })
  236. .from(SessionMessageTable)
  237. .where(eq(SessionMessageTable.type, "compaction"))
  238. .all()
  239. .pipe(Effect.orDie),
  240. ).toEqual([])
  241. yield* events.publish(SessionEvent.Compaction.Ended, {
  242. sessionID,
  243. messageID: compactionID,
  244. timestamp: DateTime.makeUnsafe(1),
  245. reason: "manual",
  246. text: "summary",
  247. recent: "recent context",
  248. })
  249. const rows = yield* db
  250. .select()
  251. .from(SessionMessageTable)
  252. .where(eq(SessionMessageTable.session_id, sessionID))
  253. .orderBy(asc(SessionMessageTable.seq))
  254. .all()
  255. .pipe(Effect.orDie)
  256. const messages = rows.map((row) =>
  257. Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
  258. )
  259. expect(messages.map((message) => message.type)).toEqual([
  260. "agent-switched",
  261. "model-switched",
  262. "synthetic",
  263. "shell",
  264. "compaction",
  265. ])
  266. expect(messages.find((message) => message.type === "shell")).toMatchObject({
  267. output: "/project",
  268. time: { completed: DateTime.makeUnsafe(1) },
  269. })
  270. expect(messages.find((message) => message.type === "compaction")).toMatchObject({
  271. summary: "summary",
  272. recent: "recent context",
  273. })
  274. expect(
  275. yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
  276. ).toMatchObject({
  277. agent: "build",
  278. model,
  279. time_updated: DateTime.toEpochMillis(created),
  280. })
  281. }),
  282. )
  283. it.effect("rejects distinct creator events that reuse one projected message ID", () =>
  284. Effect.gen(function* () {
  285. const { db } = yield* Database.Service
  286. yield* db
  287. .insert(ProjectTable)
  288. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  289. .run()
  290. .pipe(Effect.orDie)
  291. yield* db
  292. .insert(SessionTable)
  293. .values({
  294. id: sessionID,
  295. project_id: Project.ID.global,
  296. slug: "test",
  297. directory: "/project",
  298. title: "test",
  299. version: "test",
  300. })
  301. .run()
  302. .pipe(Effect.orDie)
  303. const events = yield* EventV2.Service
  304. const id = SessionMessage.ID.make("msg_creator_collision")
  305. yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" })
  306. const exit = yield* events
  307. .publish(SessionEvent.Step.Started, {
  308. sessionID,
  309. assistantMessageID: id,
  310. timestamp: created,
  311. agent: "build",
  312. model,
  313. })
  314. .pipe(Effect.exit)
  315. expect(exit._tag).toBe("Failure")
  316. expect(
  317. yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
  318. ).toMatchObject({ type: "synthetic" })
  319. }),
  320. )
  321. it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () =>
  322. Effect.gen(function* () {
  323. const { db } = yield* Database.Service
  324. yield* db
  325. .insert(ProjectTable)
  326. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  327. .run()
  328. .pipe(Effect.orDie)
  329. yield* db
  330. .insert(SessionTable)
  331. .values({
  332. id: sessionID,
  333. project_id: Project.ID.global,
  334. slug: "test",
  335. directory: "/project",
  336. title: "test",
  337. version: "test",
  338. })
  339. .run()
  340. .pipe(Effect.orDie)
  341. const events = yield* EventV2.Service
  342. const id = SessionMessage.ID.make("msg_conflict")
  343. yield* SessionInput.admit(db, events, {
  344. id,
  345. sessionID,
  346. prompt: new Prompt({ text: "admitted" }),
  347. delivery: "steer",
  348. })
  349. const exit = yield* events
  350. .publish(SessionEvent.Prompted, {
  351. sessionID,
  352. messageID: id,
  353. timestamp: created,
  354. prompt: new Prompt({ text: "different" }),
  355. delivery: "steer",
  356. })
  357. .pipe(Effect.exit)
  358. expect(String(exit)).toContain("SessionInput.LifecycleConflict")
  359. expect(
  360. yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
  361. ).toMatchObject({ promoted_seq: null })
  362. }),
  363. )
  364. it.effect("rejects an assistant message ID that conflicts with an admitted inbox row", () =>
  365. Effect.gen(function* () {
  366. const { db } = yield* Database.Service
  367. yield* db
  368. .insert(ProjectTable)
  369. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  370. .run()
  371. .pipe(Effect.orDie)
  372. yield* db
  373. .insert(SessionTable)
  374. .values({
  375. id: sessionID,
  376. project_id: Project.ID.global,
  377. slug: "test",
  378. directory: "/project",
  379. title: "test",
  380. version: "test",
  381. })
  382. .run()
  383. .pipe(Effect.orDie)
  384. const events = yield* EventV2.Service
  385. const id = SessionMessage.ID.make("msg_conflict")
  386. yield* SessionInput.admit(db, events, {
  387. id,
  388. sessionID,
  389. prompt: new Prompt({ text: "admitted" }),
  390. delivery: "steer",
  391. })
  392. const exit = yield* events
  393. .publish(SessionEvent.Step.Started, {
  394. sessionID,
  395. timestamp: created,
  396. assistantMessageID: id,
  397. agent: "build",
  398. model,
  399. })
  400. .pipe(Effect.exit)
  401. expect(String(exit)).toContain("SessionInput.LifecycleConflict")
  402. expect(
  403. yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
  404. ).toBeUndefined()
  405. }),
  406. )
  407. it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () =>
  408. Effect.gen(function* () {
  409. const { db } = yield* Database.Service
  410. yield* db
  411. .insert(ProjectTable)
  412. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  413. .run()
  414. .pipe(Effect.orDie)
  415. yield* db
  416. .insert(SessionTable)
  417. .values({
  418. id: sessionID,
  419. project_id: Project.ID.global,
  420. slug: "test",
  421. directory: "/project",
  422. title: "test",
  423. version: "test",
  424. })
  425. .run()
  426. .pipe(Effect.orDie)
  427. const events = yield* EventV2.Service
  428. const id = SessionMessage.ID.make("msg_delivery_conflict")
  429. const prompt = new Prompt({ text: "admitted" })
  430. yield* SessionInput.admit(db, events, { id, sessionID, prompt, delivery: "queue" })
  431. const exit = yield* events
  432. .publish(SessionEvent.Prompted, { sessionID, messageID: id, timestamp: created, prompt, delivery: "steer" })
  433. .pipe(Effect.exit)
  434. expect(String(exit)).toContain("SessionInput.LifecycleConflict")
  435. expect(
  436. yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
  437. ).toMatchObject({ delivery: "queue", promoted_seq: null })
  438. }),
  439. )
  440. it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
  441. Effect.gen(function* () {
  442. const stale = new SessionMessage.Assistant({
  443. id: SessionMessage.ID.make("msg_assistant_stale"),
  444. type: "assistant",
  445. agent: "build",
  446. model,
  447. content: [],
  448. time: { created },
  449. })
  450. const completed = new SessionMessage.Assistant({
  451. id: SessionMessage.ID.make("msg_assistant_completed"),
  452. type: "assistant",
  453. agent: "build",
  454. model,
  455. content: [],
  456. time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
  457. })
  458. expect(
  459. yield* SessionMessageUpdater.memory({ messages: [stale, completed] }).getCurrentAssistant(),
  460. ).toBeUndefined()
  461. }),
  462. )
  463. it.effect("updates only the newest incomplete assistant projection", () =>
  464. Effect.gen(function* () {
  465. const { db } = yield* Database.Service
  466. yield* db
  467. .insert(ProjectTable)
  468. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  469. .run()
  470. .pipe(Effect.orDie)
  471. yield* db
  472. .insert(SessionTable)
  473. .values({
  474. id: sessionID,
  475. project_id: Project.ID.global,
  476. slug: "test",
  477. directory: "/project",
  478. title: "test",
  479. version: "test",
  480. })
  481. .run()
  482. .pipe(Effect.orDie)
  483. yield* db
  484. .insert(SessionMessageTable)
  485. .values([
  486. assistantRow(SessionMessage.ID.make("msg_assistant_1"), 0),
  487. assistantRow(SessionMessage.ID.make("msg_assistant_2"), 1),
  488. ])
  489. .run()
  490. .pipe(Effect.orDie)
  491. const service = yield* EventV2.Service
  492. yield* service.publish(SessionEvent.Step.Ended, {
  493. sessionID,
  494. timestamp: DateTime.makeUnsafe(1),
  495. assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
  496. finish: "stop",
  497. cost: 0,
  498. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  499. })
  500. const rows = yield* db
  501. .select()
  502. .from(SessionMessageTable)
  503. .where(eq(SessionMessageTable.session_id, sessionID))
  504. .orderBy(asc(SessionMessageTable.id))
  505. .all()
  506. .pipe(Effect.orDie)
  507. const messages = rows.map((row) =>
  508. Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
  509. )
  510. expect(messages[0]).not.toHaveProperty("time.completed")
  511. expect(messages[1]).toMatchObject({
  512. type: "assistant",
  513. finish: "stop",
  514. time: { completed: DateTime.makeUnsafe(1) },
  515. })
  516. }),
  517. )
  518. it.effect("does not revive a stale incomplete assistant projection", () =>
  519. Effect.gen(function* () {
  520. const { db } = yield* Database.Service
  521. yield* db
  522. .insert(ProjectTable)
  523. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  524. .run()
  525. .pipe(Effect.orDie)
  526. yield* db
  527. .insert(SessionTable)
  528. .values({
  529. id: sessionID,
  530. project_id: Project.ID.global,
  531. slug: "test",
  532. directory: "/project",
  533. title: "test",
  534. version: "test",
  535. })
  536. .run()
  537. .pipe(Effect.orDie)
  538. yield* db
  539. .insert(SessionMessageTable)
  540. .values([
  541. assistantRow(SessionMessage.ID.make("msg_assistant_stale"), 0),
  542. assistantRow(SessionMessage.ID.make("msg_assistant_completed"), 1, {
  543. created: DateTime.makeUnsafe(1),
  544. completed: DateTime.makeUnsafe(2),
  545. }),
  546. ])
  547. .run()
  548. .pipe(Effect.orDie)
  549. const service = yield* EventV2.Service
  550. yield* service.publish(SessionEvent.Text.Started, {
  551. sessionID,
  552. assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
  553. timestamp: DateTime.makeUnsafe(3),
  554. textID: "text-stale",
  555. })
  556. const rows = yield* db
  557. .select()
  558. .from(SessionMessageTable)
  559. .where(eq(SessionMessageTable.session_id, sessionID))
  560. .orderBy(asc(SessionMessageTable.id))
  561. .all()
  562. .pipe(Effect.orDie)
  563. const messages = rows.map((row) =>
  564. Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
  565. )
  566. expect(messages).toEqual([
  567. new SessionMessage.Assistant({
  568. id: SessionMessage.ID.make("msg_assistant_completed"),
  569. type: "assistant",
  570. agent: "build",
  571. model,
  572. content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })],
  573. time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
  574. }),
  575. new SessionMessage.Assistant({
  576. id: SessionMessage.ID.make("msg_assistant_stale"),
  577. type: "assistant",
  578. agent: "build",
  579. model,
  580. content: [],
  581. time: { created },
  582. }),
  583. ])
  584. }),
  585. )
  586. })