session-projector.test.ts 18 KB

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