session-projector.test.ts 18 KB

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