instruction-state.test.ts 17 KB

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