instruction-state.test.ts 19 KB

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