session-projector.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828
  1. import { describe, expect } from "bun:test"
  2. import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
  3. import { asc, eq, sql } from "drizzle-orm"
  4. import { Database } from "@opencode-ai/core/database/database"
  5. import { Agent } from "@opencode-ai/core/agent"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  8. import { Bus } from "@opencode-ai/core/bus"
  9. import { Event } from "@opencode-ai/schema/event"
  10. import { EventTable } from "@opencode-ai/core/event/sql"
  11. import { Model } from "@opencode-ai/core/model"
  12. import { Project } from "@opencode-ai/core/project"
  13. import { ProjectTable } from "@opencode-ai/core/project/sql"
  14. import { Provider } from "@opencode-ai/core/provider"
  15. import { AbsolutePath } from "@opencode-ai/core/schema"
  16. import { Session } from "@opencode-ai/core/session"
  17. import { SessionEvent } from "@opencode-ai/core/session/event"
  18. import { SessionMessage } from "@opencode-ai/core/session/message"
  19. import { Money } from "@opencode-ai/schema/money"
  20. import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
  21. import { SessionProjector } from "@opencode-ai/core/session/projector"
  22. import { SessionExecution } from "@opencode-ai/core/session/execution"
  23. import { fromRow } from "@opencode-ai/core/session/info"
  24. import { SessionPending } from "@opencode-ai/core/session/pending"
  25. import { Shell } from "@opencode-ai/schema/shell"
  26. import {
  27. InstructionStateTable,
  28. SessionPendingTable,
  29. SessionMessageTable,
  30. SessionTable,
  31. } from "@opencode-ai/core/session/sql"
  32. import { testEffect } from "./lib/effect"
  33. import { Snapshot } from "@opencode-ai/core/snapshot"
  34. const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
  35. const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
  36. const sessionID = Session.ID.make("ses_projector_test")
  37. const created = DateTime.makeUnsafe(0)
  38. const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }
  39. const previousModel = { ...model, variant: Model.VariantID.make("medium") }
  40. const encodeMessage = Schema.encodeSync(SessionMessage.Info)
  41. const build = Agent.defaultID
  42. const assistantRow = (
  43. id: SessionMessage.ID,
  44. seq: number,
  45. time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
  46. usage?: Pick<SessionMessage.Assistant, "cost" | "tokens">,
  47. ) => {
  48. const {
  49. id: _,
  50. type,
  51. ...data
  52. } = encodeMessage(
  53. SessionMessage.Assistant.make({ id, type: "assistant", agent: build, model, content: [], time, ...usage }),
  54. )
  55. return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
  56. }
  57. describe("SessionProjector", () => {
  58. it.effect("does not settle a pending manual compaction on an auto failure", () =>
  59. Effect.gen(function* () {
  60. const db = (yield* Database.Service).db
  61. yield* db
  62. .insert(ProjectTable)
  63. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  64. .run()
  65. yield* db
  66. .insert(SessionTable)
  67. .values({
  68. id: sessionID,
  69. project_id: Project.ID.global,
  70. slug: "test",
  71. directory: "/project",
  72. title: "test",
  73. version: "test",
  74. })
  75. .run()
  76. const bus = yield* Bus.Service
  77. const inputID = SessionMessage.ID.make("msg_manual_compaction")
  78. yield* SessionPending.admitCompaction(db, bus, { id: inputID, sessionID })
  79. yield* bus.publish(SessionEvent.Compaction.Failed, {
  80. sessionID,
  81. reason: "auto",
  82. error: { type: "compaction.failed", message: "Auto compaction failed" },
  83. })
  84. expect(yield* SessionPending.compaction(db, sessionID)).toMatchObject({ id: inputID })
  85. }),
  86. )
  87. it.effect("loads legacy revert storage into canonical state", () =>
  88. Effect.gen(function* () {
  89. const db = (yield* Database.Service).db
  90. yield* db
  91. .insert(ProjectTable)
  92. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  93. .run()
  94. yield* db
  95. .insert(SessionTable)
  96. .values({
  97. id: sessionID,
  98. project_id: Project.ID.global,
  99. slug: "test",
  100. directory: "/project",
  101. title: "test",
  102. version: "test",
  103. })
  104. .run()
  105. const legacy = JSON.stringify({
  106. messageID: "msg_boundary",
  107. snapshot: "tree",
  108. diff: "legacy patch",
  109. files: [{ path: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }],
  110. })
  111. yield* db.run(sql`update session set revert = ${legacy} where id = ${sessionID}`)
  112. const stored = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()
  113. if (!stored) return yield* Effect.die("Session row missing")
  114. const storedRevert = fromRow(stored).revert
  115. expect(String(storedRevert?.messageID)).toBe("msg_boundary")
  116. expect(String(storedRevert?.snapshot)).toBe("tree")
  117. expect(storedRevert?.files).toEqual([
  118. { file: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" },
  119. ])
  120. }),
  121. )
  122. it.effect("folds live compaction deltas into running memory state", () =>
  123. Effect.gen(function* () {
  124. const state = {
  125. messages: [
  126. SessionMessage.CompactionRunning.make({
  127. id: SessionMessage.ID.make("msg_compaction"),
  128. type: "compaction",
  129. status: "running",
  130. reason: "manual",
  131. summary: "partial ",
  132. recent: "recent",
  133. time: { created },
  134. }),
  135. ],
  136. }
  137. yield* SessionMessageUpdater.update(
  138. SessionMessageUpdater.memory(state),
  139. SessionEvent.Compaction.Delta.make({
  140. id: Event.ID.make("evt_delta"),
  141. type: "session.compaction.delta",
  142. created,
  143. data: { sessionID, text: "summary" },
  144. }),
  145. )
  146. expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" })
  147. }),
  148. )
  149. it.effect("projects staged, cleared, and committed reverts", () =>
  150. Effect.gen(function* () {
  151. const db = (yield* Database.Service).db
  152. yield* db
  153. .insert(ProjectTable)
  154. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  155. .run()
  156. yield* db
  157. .insert(SessionTable)
  158. .values({
  159. id: sessionID,
  160. project_id: Project.ID.global,
  161. slug: "test",
  162. directory: "/project",
  163. title: "test",
  164. version: "test",
  165. cost: 1.25,
  166. tokens_input: 10,
  167. tokens_output: 4,
  168. tokens_reasoning: 2,
  169. tokens_cache_read: 3,
  170. tokens_cache_write: 1,
  171. })
  172. .run()
  173. const boundary = SessionMessage.ID.make("msg_boundary")
  174. const earlier = SessionMessage.ID.make("msg_earlier")
  175. yield* db
  176. .insert(SessionMessageTable)
  177. .values([
  178. assistantRow(earlier, 0),
  179. assistantRow(
  180. boundary,
  181. 1,
  182. { created },
  183. {
  184. cost: Money.USD.make(0.5),
  185. tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
  186. },
  187. ),
  188. assistantRow(
  189. SessionMessage.ID.make("msg_later"),
  190. 2,
  191. { created },
  192. {
  193. cost: Money.USD.make(0.75),
  194. tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
  195. },
  196. ),
  197. ])
  198. .run()
  199. yield* db
  200. .insert(InstructionStateTable)
  201. .values({
  202. session_id: sessionID,
  203. epoch_start: 0,
  204. through_seq: 0,
  205. initial_values: {},
  206. current_values: {},
  207. })
  208. .run()
  209. const bus = yield* Bus.Service
  210. yield* bus.publish(SessionEvent.RevertEvent.Staged, {
  211. sessionID,
  212. revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), files: [] },
  213. })
  214. expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({
  215. messageID: boundary,
  216. snapshot: "tree",
  217. files: [],
  218. })
  219. yield* bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
  220. expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull()
  221. yield* bus.publish(SessionEvent.RevertEvent.Staged, {
  222. sessionID,
  223. revert: { messageID: boundary, files: [] },
  224. })
  225. yield* bus.publish(SessionEvent.RevertEvent.Committed, {
  226. sessionID,
  227. to: boundary,
  228. })
  229. expect(
  230. (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
  231. ).toEqual([earlier])
  232. expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({
  233. cost: Money.USD.make(1.25),
  234. tokens_input: 10,
  235. tokens_output: 4,
  236. tokens_reasoning: 2,
  237. tokens_cache_read: 3,
  238. tokens_cache_write: 1,
  239. })
  240. // A committed revert resets the fold cache so the next boundary establishes a new epoch.
  241. expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
  242. }),
  243. )
  244. it.effect("orders projected messages and context by durable aggregate sequence", () =>
  245. Effect.gen(function* () {
  246. const { db } = yield* Database.Service
  247. yield* db
  248. .insert(ProjectTable)
  249. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  250. .run()
  251. .pipe(Effect.orDie)
  252. yield* db
  253. .insert(SessionTable)
  254. .values({
  255. id: sessionID,
  256. project_id: Project.ID.global,
  257. slug: "test",
  258. directory: "/project",
  259. title: "test",
  260. version: "test",
  261. })
  262. .run()
  263. .pipe(Effect.orDie)
  264. const bus = yield* Bus.Service
  265. yield* bus.publish(SessionEvent.InputAdmitted, {
  266. sessionID,
  267. inputID: SessionMessage.ID.make("msg_first"),
  268. input: { type: "user", data: { text: "first" }, delivery: "steer" },
  269. })
  270. yield* bus.publish(
  271. SessionEvent.InputPromoted,
  272. {
  273. sessionID,
  274. inputID: SessionMessage.ID.make("msg_first"),
  275. },
  276. { id: Event.ID.make("evt_z") },
  277. )
  278. yield* bus.publish(SessionEvent.InputAdmitted, {
  279. sessionID,
  280. inputID: SessionMessage.ID.make("msg_second"),
  281. input: { type: "user", data: { text: "second" }, delivery: "steer" },
  282. })
  283. yield* bus.publish(
  284. SessionEvent.InputPromoted,
  285. {
  286. sessionID,
  287. inputID: SessionMessage.ID.make("msg_second"),
  288. },
  289. { id: Event.ID.make("evt_a") },
  290. )
  291. const sessions = yield* Session.Service
  292. const firstPage = yield* sessions.messages({ sessionID, limit: 1, order: "asc" })
  293. expect(firstPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["first"])
  294. const secondPage = yield* sessions.messages({
  295. sessionID,
  296. limit: 1,
  297. order: "asc",
  298. cursor: { id: firstPage[0]!.id, direction: "next" },
  299. })
  300. expect(secondPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["second"])
  301. expect(
  302. (yield* sessions.messages({
  303. sessionID,
  304. limit: 1,
  305. order: "asc",
  306. cursor: { id: secondPage[0]!.id, direction: "previous" },
  307. })).map((message) => (message.type === "user" ? message.text : message.type)),
  308. ).toEqual(["first"])
  309. expect(
  310. (yield* sessions.context(sessionID)).map((message) => (message.type === "user" ? message.text : message.type)),
  311. ).toEqual(["first", "second"])
  312. }).pipe(Effect.provide(sessionsLayer)),
  313. )
  314. it.effect("consumes the pending row and projects the message at promotion", () =>
  315. Effect.gen(function* () {
  316. const { db } = yield* Database.Service
  317. yield* db
  318. .insert(ProjectTable)
  319. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  320. .run()
  321. .pipe(Effect.orDie)
  322. yield* db
  323. .insert(SessionTable)
  324. .values({
  325. id: sessionID,
  326. project_id: Project.ID.global,
  327. slug: "test",
  328. directory: "/project",
  329. title: "test",
  330. version: "test",
  331. })
  332. .run()
  333. .pipe(Effect.orDie)
  334. const bus = yield* Bus.Service
  335. const id = SessionMessage.ID.make("msg_admitted")
  336. const admitted = yield* SessionPending.admit(db, bus, {
  337. id,
  338. sessionID,
  339. input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
  340. })
  341. if (!admitted) return yield* Effect.die("Prompt admission failed")
  342. const event = yield* bus.publish(SessionEvent.InputPromoted, {
  343. sessionID,
  344. inputID: id,
  345. })
  346. expect(
  347. yield* db.select().from(SessionPendingTable).where(eq(SessionPendingTable.id, id)).get().pipe(Effect.orDie),
  348. ).toBeUndefined()
  349. expect(
  350. yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
  351. ).toMatchObject({ session_id: sessionID, type: "user", seq: event.durable?.seq })
  352. }),
  353. )
  354. it.effect("projects durable context messages supported by the updater", () =>
  355. Effect.gen(function* () {
  356. const { db } = yield* Database.Service
  357. yield* db
  358. .insert(ProjectTable)
  359. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  360. .run()
  361. .pipe(Effect.orDie)
  362. yield* db
  363. .insert(SessionTable)
  364. .values({
  365. id: sessionID,
  366. project_id: Project.ID.global,
  367. slug: "test",
  368. directory: "/project",
  369. title: "test",
  370. version: "test",
  371. model: previousModel,
  372. })
  373. .run()
  374. .pipe(Effect.orDie)
  375. const bus = yield* Bus.Service
  376. yield* bus.publish(SessionEvent.AgentSelected, {
  377. sessionID,
  378. agent: build,
  379. })
  380. yield* bus.publish(SessionEvent.ModelSelected, {
  381. sessionID,
  382. model,
  383. })
  384. yield* bus.publish(SessionEvent.Synthetic, {
  385. sessionID,
  386. text: "synthetic context",
  387. metadata: { source: "projector-test" },
  388. })
  389. yield* bus.publish(SessionEvent.Shell.Started, {
  390. sessionID,
  391. shell: Shell.Info.make({
  392. id: Shell.ID.make("sh_projector"),
  393. status: "running",
  394. command: "pwd",
  395. cwd: "/project",
  396. shell: "/bin/sh",
  397. file: "/tmp/sh_projector.out",
  398. metadata: {},
  399. time: { started: 0 },
  400. }),
  401. })
  402. yield* bus.publish(SessionEvent.Shell.Ended, {
  403. sessionID,
  404. shell: Shell.Info.make({
  405. id: Shell.ID.make("sh_projector"),
  406. status: "exited",
  407. command: "pwd",
  408. cwd: "/project",
  409. shell: "/bin/sh",
  410. file: "/tmp/sh_projector.out",
  411. exit: 0,
  412. metadata: {},
  413. time: { started: 0, completed: 1 },
  414. }),
  415. output: { output: "/project", cursor: 8, size: 8, truncated: false },
  416. })
  417. yield* bus.publish(SessionEvent.Compaction.Started, {
  418. sessionID,
  419. reason: "manual",
  420. recent: "recent context",
  421. })
  422. yield* bus.publish(SessionEvent.Compaction.Delta, {
  423. sessionID,
  424. text: "partial",
  425. })
  426. expect(
  427. yield* db
  428. .select({ id: EventTable.id })
  429. .from(EventTable)
  430. .where(sql`${EventTable.type} like 'session.compaction.delta.%'`)
  431. .all()
  432. .pipe(Effect.orDie),
  433. ).toHaveLength(0)
  434. expect(
  435. yield* db
  436. .select({ data: SessionMessageTable.data })
  437. .from(SessionMessageTable)
  438. .where(eq(SessionMessageTable.type, "compaction"))
  439. .all()
  440. .pipe(Effect.orDie),
  441. ).toEqual([{ data: expect.objectContaining({ status: "running", summary: "", recent: "recent context" }) }])
  442. yield* bus.publish(SessionEvent.Compaction.Ended, {
  443. sessionID,
  444. reason: "manual",
  445. text: "summary",
  446. recent: "recent context",
  447. })
  448. const rows = yield* db
  449. .select()
  450. .from(SessionMessageTable)
  451. .where(eq(SessionMessageTable.session_id, sessionID))
  452. .orderBy(asc(SessionMessageTable.seq))
  453. .all()
  454. .pipe(Effect.orDie)
  455. const messages = rows.map((row) =>
  456. Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
  457. )
  458. expect(messages.map((message) => message.type)).toEqual([
  459. "agent-switched",
  460. "model-switched",
  461. "synthetic",
  462. "shell",
  463. "compaction",
  464. ])
  465. expect(messages.find((message) => message.type === "synthetic")).toMatchObject({
  466. text: "synthetic context",
  467. metadata: { source: "projector-test" },
  468. })
  469. expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
  470. expect(messages.find((message) => message.type === "shell")).toMatchObject({
  471. command: "pwd",
  472. status: "exited",
  473. exit: 0,
  474. output: { output: "/project", truncated: false },
  475. time: { completed: DateTime.makeUnsafe(0) },
  476. })
  477. expect(messages.find((message) => message.type === "compaction")).toMatchObject({
  478. summary: "summary",
  479. recent: "recent context",
  480. })
  481. expect(
  482. yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
  483. ).toMatchObject({
  484. agent: "build",
  485. model,
  486. time_updated: DateTime.toEpochMillis(created),
  487. })
  488. }),
  489. )
  490. it.effect("rejects distinct creator events that reuse one projected message ID", () =>
  491. Effect.gen(function* () {
  492. const { db } = yield* Database.Service
  493. yield* db
  494. .insert(ProjectTable)
  495. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  496. .run()
  497. .pipe(Effect.orDie)
  498. yield* db
  499. .insert(SessionTable)
  500. .values({
  501. id: sessionID,
  502. project_id: Project.ID.global,
  503. slug: "test",
  504. directory: "/project",
  505. title: "test",
  506. version: "test",
  507. })
  508. .run()
  509. .pipe(Effect.orDie)
  510. const bus = yield* Bus.Service
  511. const id = SessionMessage.ID.make("msg_creator_collision")
  512. const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
  513. yield* db
  514. .insert(SessionMessageTable)
  515. .values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
  516. .run()
  517. const exit = yield* bus
  518. .publish(SessionEvent.Step.Started, {
  519. sessionID,
  520. assistantMessageID: id,
  521. agent: build,
  522. model,
  523. })
  524. .pipe(Effect.exit)
  525. expect(exit._tag).toBe("Failure")
  526. expect(
  527. yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
  528. ).toMatchObject({ type: "synthetic" })
  529. }),
  530. )
  531. it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
  532. Effect.gen(function* () {
  533. const stale = SessionMessage.Assistant.make({
  534. id: SessionMessage.ID.make("msg_assistant_stale"),
  535. type: "assistant",
  536. agent: build,
  537. model,
  538. content: [],
  539. time: { created },
  540. })
  541. const completed = SessionMessage.Assistant.make({
  542. id: SessionMessage.ID.make("msg_assistant_completed"),
  543. type: "assistant",
  544. agent: build,
  545. model,
  546. content: [],
  547. time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
  548. })
  549. expect(
  550. yield* SessionMessageUpdater.memory({ messages: [stale, completed] }).getCurrentAssistant(),
  551. ).toBeUndefined()
  552. }),
  553. )
  554. it.effect("projects retry state and clears it at the next step or execution terminal", () =>
  555. Effect.gen(function* () {
  556. const { db } = yield* Database.Service
  557. yield* db
  558. .insert(ProjectTable)
  559. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  560. .run()
  561. .pipe(Effect.orDie)
  562. yield* db
  563. .insert(SessionTable)
  564. .values({
  565. id: sessionID,
  566. project_id: Project.ID.global,
  567. slug: "test",
  568. directory: "/project",
  569. title: "test",
  570. version: "test",
  571. })
  572. .run()
  573. .pipe(Effect.orDie)
  574. const bus = yield* Bus.Service
  575. const first = SessionMessage.ID.make("msg_retry_first")
  576. const second = SessionMessage.ID.make("msg_retry_second")
  577. yield* bus.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: build, model })
  578. yield* bus.publish(SessionEvent.RetryScheduled, {
  579. sessionID,
  580. assistantMessageID: first,
  581. attempt: 2,
  582. at: 2_000,
  583. error: { type: "provider.transport", message: "Disconnected" },
  584. })
  585. const decode = (row: typeof SessionMessageTable.$inferSelect) =>
  586. Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type })
  587. const firstRow = yield* db
  588. .select()
  589. .from(SessionMessageTable)
  590. .where(eq(SessionMessageTable.id, first))
  591. .get()
  592. .pipe(Effect.orDie)
  593. const projected = firstRow ?? (yield* Effect.die(new Error("Missing retry projection")))
  594. expect(decode(projected)).toMatchObject({
  595. retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
  596. })
  597. yield* bus.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: build, model })
  598. yield* bus.publish(SessionEvent.RetryScheduled, {
  599. sessionID,
  600. assistantMessageID: second,
  601. attempt: 3,
  602. at: 6_000,
  603. error: { type: "provider.internal", message: "Unavailable" },
  604. })
  605. yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
  606. const rows = yield* db
  607. .select()
  608. .from(SessionMessageTable)
  609. .where(eq(SessionMessageTable.session_id, sessionID))
  610. .orderBy(asc(SessionMessageTable.seq))
  611. .all()
  612. .pipe(Effect.orDie)
  613. expect(decode(rows[0])).not.toHaveProperty("retry")
  614. expect(decode(rows[1])).not.toHaveProperty("retry")
  615. }),
  616. )
  617. it.effect("does not infer restart continuation from lifecycle history", () =>
  618. Effect.gen(function* () {
  619. const { db } = yield* Database.Service
  620. yield* db
  621. .insert(ProjectTable)
  622. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  623. .run()
  624. .pipe(Effect.orDie)
  625. yield* db
  626. .insert(SessionTable)
  627. .values({
  628. id: sessionID,
  629. project_id: Project.ID.global,
  630. slug: "test",
  631. directory: "/project",
  632. title: "test",
  633. version: "test",
  634. })
  635. .run()
  636. .pipe(Effect.orDie)
  637. const bus = yield* Bus.Service
  638. const suspended = () =>
  639. db
  640. .select({ timeSuspended: SessionTable.time_suspended })
  641. .from(SessionTable)
  642. .where(eq(SessionTable.id, sessionID))
  643. .get()
  644. .pipe(Effect.orDie)
  645. yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
  646. expect((yield* suspended())?.timeSuspended).toBeNull()
  647. yield* bus.publish(SessionEvent.Execution.Started, { sessionID })
  648. expect((yield* suspended())?.timeSuspended).toBeNull()
  649. }),
  650. )
  651. it.effect("updates only the newest incomplete assistant projection", () =>
  652. Effect.gen(function* () {
  653. const { db } = yield* Database.Service
  654. yield* db
  655. .insert(ProjectTable)
  656. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  657. .run()
  658. .pipe(Effect.orDie)
  659. yield* db
  660. .insert(SessionTable)
  661. .values({
  662. id: sessionID,
  663. project_id: Project.ID.global,
  664. slug: "test",
  665. directory: "/project",
  666. title: "test",
  667. version: "test",
  668. })
  669. .run()
  670. .pipe(Effect.orDie)
  671. yield* db
  672. .insert(SessionMessageTable)
  673. .values([
  674. assistantRow(SessionMessage.ID.make("msg_assistant_1"), 0),
  675. assistantRow(SessionMessage.ID.make("msg_assistant_2"), 1),
  676. ])
  677. .run()
  678. .pipe(Effect.orDie)
  679. const service = yield* Bus.Service
  680. const usageUpdated = yield* service
  681. .subscribe(SessionEvent.UsageUpdated)
  682. .pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
  683. yield* service.publish(SessionEvent.Step.Ended, {
  684. sessionID,
  685. assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
  686. finish: "stop",
  687. cost: Money.USD.make(1.25),
  688. tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
  689. })
  690. const rows = yield* db
  691. .select()
  692. .from(SessionMessageTable)
  693. .where(eq(SessionMessageTable.session_id, sessionID))
  694. .orderBy(asc(SessionMessageTable.id))
  695. .all()
  696. .pipe(Effect.orDie)
  697. const messages = rows.map((row) =>
  698. Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
  699. )
  700. expect(messages[0]).not.toHaveProperty("time.completed")
  701. expect(messages[1]).toMatchObject({
  702. type: "assistant",
  703. finish: "stop",
  704. cost: Money.USD.make(1.25),
  705. tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
  706. time: { completed: DateTime.makeUnsafe(0) },
  707. })
  708. expect(
  709. yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
  710. ).toMatchObject({
  711. cost: 1.25,
  712. tokens_input: 10,
  713. tokens_output: 4,
  714. tokens_reasoning: 2,
  715. tokens_cache_read: 3,
  716. tokens_cache_write: 1,
  717. })
  718. expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({
  719. sessionID,
  720. cost: Money.USD.make(1.25),
  721. tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
  722. })
  723. }),
  724. )
  725. it.effect("does not revive a stale incomplete assistant projection", () =>
  726. Effect.gen(function* () {
  727. const { db } = yield* Database.Service
  728. yield* db
  729. .insert(ProjectTable)
  730. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  731. .run()
  732. .pipe(Effect.orDie)
  733. yield* db
  734. .insert(SessionTable)
  735. .values({
  736. id: sessionID,
  737. project_id: Project.ID.global,
  738. slug: "test",
  739. directory: "/project",
  740. title: "test",
  741. version: "test",
  742. })
  743. .run()
  744. .pipe(Effect.orDie)
  745. yield* db
  746. .insert(SessionMessageTable)
  747. .values([
  748. assistantRow(SessionMessage.ID.make("msg_assistant_stale"), 0),
  749. assistantRow(SessionMessage.ID.make("msg_assistant_completed"), 1, {
  750. created: DateTime.makeUnsafe(1),
  751. completed: DateTime.makeUnsafe(2),
  752. }),
  753. ])
  754. .run()
  755. .pipe(Effect.orDie)
  756. const service = yield* Bus.Service
  757. yield* service.publish(SessionEvent.Text.Started, {
  758. sessionID,
  759. assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
  760. ordinal: 0,
  761. })
  762. const rows = yield* db
  763. .select()
  764. .from(SessionMessageTable)
  765. .where(eq(SessionMessageTable.session_id, sessionID))
  766. .orderBy(asc(SessionMessageTable.id))
  767. .all()
  768. .pipe(Effect.orDie)
  769. const messages = rows.map((row) =>
  770. Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
  771. )
  772. expect(messages).toEqual([
  773. SessionMessage.Assistant.make({
  774. id: SessionMessage.ID.make("msg_assistant_completed"),
  775. type: "assistant",
  776. agent: build,
  777. model,
  778. content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
  779. time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
  780. }),
  781. SessionMessage.Assistant.make({
  782. id: SessionMessage.ID.make("msg_assistant_stale"),
  783. type: "assistant",
  784. agent: build,
  785. model,
  786. content: [],
  787. time: { created },
  788. }),
  789. ])
  790. }),
  791. )
  792. })