session-projector.test.ts 16 KB

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