session-projector.test.ts 27 KB

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