1
0

session-projector.test.ts 18 KB

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