1
0

instruction-state.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. import { describe, expect } from "bun:test"
  2. import { and, asc, eq } from "drizzle-orm"
  3. import { Effect, Schema } from "effect"
  4. import { Database } from "@opencode-ai/core/database/database"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { Bus } from "@opencode-ai/core/bus"
  8. import { Event } from "@opencode-ai/schema/event"
  9. import { EventTable } from "@opencode-ai/core/event/sql"
  10. import { Instructions } from "@opencode-ai/core/instructions"
  11. import { Project } from "@opencode-ai/core/project"
  12. import { ProjectTable } from "@opencode-ai/core/project/sql"
  13. import { AbsolutePath } from "@opencode-ai/core/schema"
  14. import { InstructionState } from "@opencode-ai/core/session/instruction-state"
  15. import { SessionProjector } from "@opencode-ai/core/session/projector"
  16. import { SessionSchema } from "@opencode-ai/core/session/schema"
  17. import {
  18. InstructionBlobTable,
  19. InstructionStateTable,
  20. SessionMessageTable,
  21. SessionTable,
  22. } from "@opencode-ai/core/session/sql"
  23. import { testEffect } from "./lib/effect"
  24. const it = testEffect(
  25. AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
  26. [Bus.node, Bus.configured({ persist: true })],
  27. ]),
  28. )
  29. const source = (name: string, read: Effect.Effect<string | Instructions.Unavailable | Instructions.Removed>) =>
  30. Instructions.make({
  31. key: Instructions.Key.make(name),
  32. codec: Schema.toCodecJson(Schema.String),
  33. read,
  34. render: {
  35. initial: String,
  36. changed: (_previous, current) => current,
  37. removed: (previous) => `Removed ${previous}`,
  38. },
  39. })
  40. const setup = (sessionID: SessionSchema.ID) =>
  41. Effect.gen(function* () {
  42. const { db } = yield* Database.Service
  43. yield* db
  44. .insert(ProjectTable)
  45. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  46. .onConflictDoNothing()
  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: "instruction-state-test",
  55. directory: "/project",
  56. title: "Instruction state test",
  57. version: "test",
  58. })
  59. .run()
  60. .pipe(Effect.orDie)
  61. return { db, events: yield* Bus.Service }
  62. })
  63. const instructionEvents = (db: Database.Interface["db"], sessionID: SessionSchema.ID) =>
  64. db
  65. .select()
  66. .from(EventTable)
  67. .where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, "session.instructions.updated.2")))
  68. .orderBy(asc(EventTable.seq))
  69. .all()
  70. .pipe(Effect.orDie)
  71. const preview = (db: Database.Interface["db"], sessionID: SessionSchema.ID, instructions: Instructions.List) =>
  72. Instructions.read(instructions).pipe(
  73. Effect.flatMap((observed) => InstructionState.preview(db, sessionID, instructions, observed)),
  74. )
  75. describe("InstructionState", () => {
  76. it.effect("observes each source once without publishing events or inserting blobs", () =>
  77. Effect.gen(function* () {
  78. const sessionID = SessionSchema.ID.make("ses_instruction_observe")
  79. const { db, events } = yield* setup(sessionID)
  80. const reads = { first: 0, second: 0 }
  81. const instructions = Instructions.combine([
  82. source(
  83. "test/first",
  84. Effect.sync(() => {
  85. reads.first++
  86. return "first"
  87. }),
  88. ),
  89. source(
  90. "test/second",
  91. Effect.sync(() => {
  92. reads.second++
  93. return "second"
  94. }),
  95. ),
  96. ])
  97. const published: Event.Payload[] = []
  98. const unsubscribe = yield* events.listen((event) =>
  99. Effect.sync(() => {
  100. if (event.type === "session.instructions.updated") published.push(event)
  101. }),
  102. )
  103. const observation = yield* InstructionState.observe(db, instructions, sessionID)
  104. yield* unsubscribe
  105. expect(reads).toEqual({ first: 1, second: 1 })
  106. expect(observation).toEqual({
  107. sessionID,
  108. initial: true,
  109. previous: {},
  110. current: {
  111. "test/first": Instructions.hash("first"),
  112. "test/second": Instructions.hash("second"),
  113. },
  114. delta: {
  115. "test/first": Instructions.hash("first"),
  116. "test/second": Instructions.hash("second"),
  117. },
  118. blobs: {
  119. [Instructions.hash("first")]: "first",
  120. [Instructions.hash("second")]: "second",
  121. },
  122. })
  123. expect(published).toEqual([])
  124. expect(yield* instructionEvents(db, sessionID)).toEqual([])
  125. expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
  126. }),
  127. )
  128. it.effect("commits initial metadata and changed and removed deltas without rereading sources", () =>
  129. Effect.gen(function* () {
  130. const sessionID = SessionSchema.ID.make("ses_instruction_commit")
  131. const { db, events } = yield* setup(sessionID)
  132. let current = "initial"
  133. let retired: string | Instructions.Removed = "retired"
  134. let reads = 0
  135. const instructions = Instructions.combine([
  136. source(
  137. "test/current",
  138. Effect.sync(() => {
  139. reads++
  140. return current
  141. }),
  142. ),
  143. source(
  144. "test/retired",
  145. Effect.sync(() => {
  146. reads++
  147. return retired
  148. }),
  149. ),
  150. ])
  151. const published: Event.Payload[] = []
  152. const unsubscribe = yield* events.listen((event) =>
  153. Effect.sync(() => {
  154. if (event.type === "session.instructions.updated") published.push(event)
  155. }),
  156. )
  157. const initial = yield* InstructionState.observe(db, instructions, sessionID)
  158. expect(reads).toBe(2)
  159. yield* InstructionState.commit(db, events, instructions, initial)
  160. expect(reads).toBe(2)
  161. current = "changed"
  162. retired = Instructions.removed
  163. const changed = yield* InstructionState.observe(db, instructions, sessionID)
  164. expect(reads).toBe(4)
  165. expect(changed).toMatchObject({
  166. sessionID,
  167. initial: false,
  168. previous: {
  169. "test/current": Instructions.hash("initial"),
  170. "test/retired": Instructions.hash("retired"),
  171. },
  172. current: { "test/current": Instructions.hash("changed") },
  173. delta: {
  174. "test/current": Instructions.hash("changed"),
  175. "test/retired": "removed",
  176. },
  177. blobs: { [Instructions.hash("changed")]: "changed" },
  178. })
  179. yield* InstructionState.commit(db, events, instructions, changed)
  180. expect(reads).toBe(4)
  181. yield* unsubscribe
  182. expect(published).toHaveLength(2)
  183. expect(published[0]?.metadata).toEqual({ instructions: { initial: true } })
  184. expect(published[1]?.metadata).toBeUndefined()
  185. expect((yield* instructionEvents(db, sessionID)).map((event) => event.data.delta)).toEqual([
  186. {
  187. "test/current": Instructions.hash("initial"),
  188. "test/retired": Instructions.hash("retired"),
  189. },
  190. {
  191. "test/current": Instructions.hash("changed"),
  192. "test/retired": "removed",
  193. },
  194. ])
  195. // The chronological update text is frozen into the event; the baseline has none.
  196. expect((yield* instructionEvents(db, sessionID)).map((event) => event.data.text)).toEqual([
  197. undefined,
  198. "changed\n\nRemoved retired",
  199. ])
  200. expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({
  201. initial_values: {
  202. "test/current": Instructions.hash("initial"),
  203. "test/retired": Instructions.hash("retired"),
  204. },
  205. current_values: { "test/current": Instructions.hash("changed") },
  206. })
  207. expect(
  208. Object.fromEntries(
  209. (yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).map((row) => [row.hash, row.value]),
  210. ),
  211. ).toEqual({
  212. [Instructions.hash("initial")]: "initial",
  213. [Instructions.hash("retired")]: "retired",
  214. [Instructions.hash("changed")]: "changed",
  215. })
  216. }),
  217. )
  218. it.effect("keeps no-op observations free of events and blobs", () =>
  219. Effect.gen(function* () {
  220. const sessionID = SessionSchema.ID.make("ses_instruction_noop")
  221. const { db, events } = yield* setup(sessionID)
  222. const instructions = source("test/context", Effect.succeed("unchanged"))
  223. yield* InstructionState.prepare(db, events, instructions, sessionID)
  224. const beforeEvents = yield* instructionEvents(db, sessionID)
  225. const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
  226. const observation = yield* InstructionState.observe(db, instructions, sessionID)
  227. expect(observation).toEqual({
  228. sessionID,
  229. initial: false,
  230. previous: { "test/context": Instructions.hash("unchanged") },
  231. current: { "test/context": Instructions.hash("unchanged") },
  232. delta: {},
  233. blobs: {},
  234. })
  235. yield* InstructionState.commit(db, events, instructions, observation)
  236. expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
  237. expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
  238. }),
  239. )
  240. it.effect("treats a missing state row as a fresh baseline without repairing it", () =>
  241. Effect.gen(function* () {
  242. const sessionID = SessionSchema.ID.make("ses_instruction_generate")
  243. const { db, events } = yield* setup(sessionID)
  244. let value = "Initial context"
  245. const instructions = source(
  246. "test/context",
  247. Effect.sync(() => value),
  248. )
  249. yield* InstructionState.prepare(db, events, instructions, sessionID)
  250. yield* db
  251. .delete(InstructionStateTable)
  252. .where(eq(InstructionStateTable.session_id, sessionID))
  253. .run()
  254. .pipe(Effect.orDie)
  255. value = "Changed context"
  256. const beforeEvents = yield* instructionEvents(db, sessionID)
  257. const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
  258. const assembled = yield* preview(db, sessionID, instructions)
  259. expect(assembled).toEqual({ initial: "Changed context", update: "" })
  260. expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
  261. expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
  262. expect(
  263. yield* db
  264. .select()
  265. .from(InstructionStateTable)
  266. .where(eq(InstructionStateTable.session_id, sessionID))
  267. .get()
  268. .pipe(Effect.orDie),
  269. ).toBeUndefined()
  270. }),
  271. )
  272. it.effect("trusts the projected state without consulting durable events", () =>
  273. Effect.gen(function* () {
  274. const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
  275. const { db, events } = yield* setup(sessionID)
  276. let value = "Initial context"
  277. const instructions = source(
  278. "test/context",
  279. Effect.sync(() => value),
  280. )
  281. yield* InstructionState.prepare(db, events, instructions, sessionID)
  282. value = "Committed update"
  283. yield* InstructionState.prepare(db, events, instructions, sessionID)
  284. // Tamper with the projected state; the authoritative row wins over event history.
  285. yield* db
  286. .update(InstructionStateTable)
  287. .set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
  288. .where(eq(InstructionStateTable.session_id, sessionID))
  289. .run()
  290. .pipe(Effect.orDie)
  291. value = "Private update"
  292. const beforeEvents = yield* instructionEvents(db, sessionID)
  293. const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
  294. const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
  295. const assembled = yield* preview(db, sessionID, instructions)
  296. expect(assembled.initial).toBe("Initial context")
  297. expect(assembled.update).toBe("Private update")
  298. expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
  299. expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
  300. expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
  301. }),
  302. )
  303. it.effect("persists chronological updates as system messages", () =>
  304. Effect.gen(function* () {
  305. const sessionID = SessionSchema.ID.make("ses_instruction_messages")
  306. const { db, events } = yield* setup(sessionID)
  307. let value = "Initial context"
  308. const instructions = source(
  309. "test/context",
  310. Effect.sync(() => value),
  311. )
  312. const messages = () =>
  313. db
  314. .select()
  315. .from(SessionMessageTable)
  316. .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "system")))
  317. .orderBy(asc(SessionMessageTable.seq))
  318. .all()
  319. .pipe(Effect.orDie)
  320. // The initial baseline is not chronological history and produces no message.
  321. yield* InstructionState.prepare(db, events, instructions, sessionID)
  322. expect(yield* messages()).toEqual([])
  323. value = "Changed context"
  324. yield* InstructionState.prepare(db, events, instructions, sessionID)
  325. const rows = yield* messages()
  326. expect(rows).toHaveLength(1)
  327. expect(rows[0]?.data).toMatchObject({ text: "Changed context" })
  328. expect(rows.map((row) => row.seq)).toEqual([(yield* instructionEvents(db, sessionID)).at(-1)!.seq])
  329. // A no-op observation adds nothing.
  330. yield* InstructionState.prepare(db, events, instructions, sessionID)
  331. expect(yield* messages()).toHaveLength(1)
  332. }),
  333. )
  334. it.effect("assembles initial instructions without persisting a baseline", () =>
  335. Effect.gen(function* () {
  336. const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
  337. const { db } = yield* setup(sessionID)
  338. const instructions = source("test/context", Effect.succeed("Initial context"))
  339. expect(yield* preview(db, sessionID, instructions)).toEqual({
  340. initial: "Initial context",
  341. update: "",
  342. })
  343. expect(yield* instructionEvents(db, sessionID)).toEqual([])
  344. expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
  345. expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
  346. }),
  347. )
  348. it.effect("retains a committed value when fresh instructions are unavailable", () =>
  349. Effect.gen(function* () {
  350. const sessionID = SessionSchema.ID.make("ses_instruction_generate_unavailable")
  351. const { db, events } = yield* setup(sessionID)
  352. let value: string | Instructions.Unavailable = "Committed context"
  353. const instructions = source(
  354. "test/context",
  355. Effect.sync(() => value),
  356. )
  357. yield* InstructionState.prepare(db, events, instructions, sessionID)
  358. value = Instructions.unavailable
  359. const beforeEvents = yield* instructionEvents(db, sessionID)
  360. const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
  361. const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
  362. expect(yield* preview(db, sessionID, instructions)).toEqual({
  363. initial: "Committed context",
  364. update: "",
  365. })
  366. expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
  367. expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
  368. expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
  369. }),
  370. )
  371. it.effect("blocks an unavailable initial instruction without persisting a baseline", () =>
  372. Effect.gen(function* () {
  373. const sessionID = SessionSchema.ID.make("ses_instruction_generate_blocked")
  374. const { db } = yield* setup(sessionID)
  375. const instructions = source("test/context", Effect.succeed(Instructions.unavailable))
  376. const error = yield* preview(db, sessionID, instructions).pipe(Effect.flip)
  377. expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
  378. expect(error.keys).toEqual([Instructions.Key.make("test/context")])
  379. expect(yield* instructionEvents(db, sessionID)).toEqual([])
  380. expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
  381. expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
  382. }),
  383. )
  384. it.effect("keeps prepare equivalent to observe followed by commit", () =>
  385. Effect.gen(function* () {
  386. const observedSessionID = SessionSchema.ID.make("ses_instruction_composed")
  387. const preparedSessionID = SessionSchema.ID.make("ses_instruction_prepared")
  388. const { db, events } = yield* setup(observedSessionID)
  389. yield* setup(preparedSessionID)
  390. let value: string | Instructions.Removed = "initial"
  391. let observedReads = 0
  392. let preparedReads = 0
  393. const observedInstructions = source(
  394. "test/context",
  395. Effect.sync(() => {
  396. observedReads++
  397. return value
  398. }),
  399. )
  400. const preparedInstructions = source(
  401. "test/context",
  402. Effect.sync(() => {
  403. preparedReads++
  404. return value
  405. }),
  406. )
  407. for (const next of ["initial", "changed", "changed", Instructions.removed] as const) {
  408. value = next
  409. yield* InstructionState.observe(db, observedInstructions, observedSessionID).pipe(
  410. Effect.flatMap((observation) => InstructionState.commit(db, events, observedInstructions, observation)),
  411. )
  412. yield* InstructionState.prepare(db, events, preparedInstructions, preparedSessionID)
  413. }
  414. expect(observedReads).toBe(4)
  415. expect(preparedReads).toBe(4)
  416. expect((yield* instructionEvents(db, observedSessionID)).map((event) => event.data.delta)).toEqual(
  417. (yield* instructionEvents(db, preparedSessionID)).map((event) => event.data.delta),
  418. )
  419. const states = yield* db.select().from(InstructionStateTable).orderBy(asc(InstructionStateTable.session_id)).all()
  420. expect(states).toHaveLength(2)
  421. expect(
  422. states.map((state) => ({
  423. epoch_start: state.epoch_start,
  424. through_seq: state.through_seq,
  425. initial_values: state.initial_values,
  426. current_values: state.current_values,
  427. })),
  428. ).toEqual([
  429. {
  430. epoch_start: 0,
  431. through_seq: 2,
  432. initial_values: { "test/context": Instructions.hash("initial") },
  433. current_values: {},
  434. },
  435. {
  436. epoch_start: 0,
  437. through_seq: 2,
  438. initial_values: { "test/context": Instructions.hash("initial") },
  439. current_values: {},
  440. },
  441. ])
  442. }),
  443. )
  444. })