instruction-state.test.ts 18 KB

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