session-projector.test.ts 20 KB

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