revert-compact.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. import { describe, expect } from "bun:test"
  2. import { SessionV1 } from "@opencode-ai/core/v1/session"
  3. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  4. import { SessionProjector } from "@opencode-ai/core/session/projector"
  5. import fs from "fs/promises"
  6. import path from "path"
  7. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  8. import { Effect } from "effect"
  9. import { Session } from "@/session/session"
  10. import { SessionRevert } from "../../src/session/revert"
  11. import { MessageV2 } from "../../src/session/message-v2"
  12. import { Snapshot } from "../../src/snapshot"
  13. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  14. import { provideTmpdirInstance } from "../fixture/fixture"
  15. import { testEffect } from "../lib/effect"
  16. import { ProviderV2 } from "@opencode-ai/core/provider"
  17. import { ModelV2 } from "@opencode-ai/core/model"
  18. const it = testEffect(
  19. LayerNode.compile(
  20. LayerNode.group([Session.node, SessionRevert.node, Snapshot.node, SessionProjector.node, CrossSpawnSpawner.node]),
  21. ),
  22. )
  23. const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") {
  24. const session = yield* Session.Service
  25. return yield* session.updateMessage({
  26. id: MessageID.ascending(),
  27. role: "user" as const,
  28. sessionID,
  29. agent,
  30. model: { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-4") },
  31. time: { created: Date.now() },
  32. })
  33. })
  34. const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, parentID: MessageID, dir: string) {
  35. const session = yield* Session.Service
  36. return yield* session.updateMessage({
  37. id: MessageID.ascending(),
  38. role: "assistant" as const,
  39. sessionID,
  40. mode: "default",
  41. agent: "default",
  42. path: { cwd: dir, root: dir },
  43. cost: 0,
  44. tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  45. modelID: ModelV2.ID.make("gpt-4"),
  46. providerID: ProviderV2.ID.make("openai"),
  47. parentID,
  48. time: { created: Date.now() },
  49. finish: "end_turn",
  50. })
  51. })
  52. const text = Effect.fn("test.text")(function* (sessionID: SessionID, messageID: MessageID, content: string) {
  53. const session = yield* Session.Service
  54. return yield* session.updatePart({
  55. id: PartID.ascending(),
  56. messageID,
  57. sessionID,
  58. type: "text" as const,
  59. text: content,
  60. })
  61. })
  62. const tool = Effect.fn("test.tool")(function* (sessionID: SessionID, messageID: MessageID) {
  63. const session = yield* Session.Service
  64. return yield* session.updatePart({
  65. id: PartID.ascending(),
  66. messageID,
  67. sessionID,
  68. type: "tool" as const,
  69. tool: "bash",
  70. callID: "call-1",
  71. state: {
  72. status: "completed" as const,
  73. input: {},
  74. output: "done",
  75. title: "",
  76. metadata: {},
  77. time: { start: 0, end: 1 },
  78. },
  79. })
  80. })
  81. const read = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8"))
  82. const write = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text))
  83. const tokens = {
  84. input: 0,
  85. output: 0,
  86. reasoning: 0,
  87. cache: { read: 0, write: 0 },
  88. }
  89. describe("revert + compact workflow", () => {
  90. it.live(
  91. "should properly handle compact command after revert",
  92. provideTmpdirInstance(
  93. (dir) =>
  94. Effect.gen(function* () {
  95. const session = yield* Session.Service
  96. const revert = yield* SessionRevert.Service
  97. const info = yield* session.create({})
  98. const sessionID = info.id
  99. const userMsg1 = yield* session.updateMessage({
  100. id: MessageID.ascending(),
  101. role: "user",
  102. sessionID,
  103. agent: "default",
  104. model: {
  105. providerID: ProviderV2.ID.make("openai"),
  106. modelID: ModelV2.ID.make("gpt-4"),
  107. },
  108. time: {
  109. created: Date.now(),
  110. },
  111. })
  112. yield* session.updatePart({
  113. id: PartID.ascending(),
  114. messageID: userMsg1.id,
  115. sessionID,
  116. type: "text",
  117. text: "Hello, please help me",
  118. })
  119. const assistantMsg1: SessionV1.Assistant = {
  120. id: MessageID.ascending(),
  121. role: "assistant",
  122. sessionID,
  123. mode: "default",
  124. agent: "default",
  125. path: {
  126. cwd: dir,
  127. root: dir,
  128. },
  129. cost: 0,
  130. tokens: {
  131. output: 0,
  132. input: 0,
  133. reasoning: 0,
  134. cache: { read: 0, write: 0 },
  135. },
  136. modelID: ModelV2.ID.make("gpt-4"),
  137. providerID: ProviderV2.ID.make("openai"),
  138. parentID: userMsg1.id,
  139. time: {
  140. created: Date.now(),
  141. },
  142. finish: "end_turn",
  143. }
  144. yield* session.updateMessage(assistantMsg1)
  145. yield* session.updatePart({
  146. id: PartID.ascending(),
  147. messageID: assistantMsg1.id,
  148. sessionID,
  149. type: "text",
  150. text: "Sure, I'll help you!",
  151. })
  152. const userMsg2 = yield* session.updateMessage({
  153. id: MessageID.ascending(),
  154. role: "user",
  155. sessionID,
  156. agent: "default",
  157. model: {
  158. providerID: ProviderV2.ID.make("openai"),
  159. modelID: ModelV2.ID.make("gpt-4"),
  160. },
  161. time: {
  162. created: Date.now(),
  163. },
  164. })
  165. yield* session.updatePart({
  166. id: PartID.ascending(),
  167. messageID: userMsg2.id,
  168. sessionID,
  169. type: "text",
  170. text: "What's the capital of France?",
  171. })
  172. const assistantMsg2: SessionV1.Assistant = {
  173. id: MessageID.ascending(),
  174. role: "assistant",
  175. sessionID,
  176. mode: "default",
  177. agent: "default",
  178. path: {
  179. cwd: dir,
  180. root: dir,
  181. },
  182. cost: 0,
  183. tokens: {
  184. output: 0,
  185. input: 0,
  186. reasoning: 0,
  187. cache: { read: 0, write: 0 },
  188. },
  189. modelID: ModelV2.ID.make("gpt-4"),
  190. providerID: ProviderV2.ID.make("openai"),
  191. parentID: userMsg2.id,
  192. time: {
  193. created: Date.now(),
  194. },
  195. finish: "end_turn",
  196. }
  197. yield* session.updateMessage(assistantMsg2)
  198. yield* session.updatePart({
  199. id: PartID.ascending(),
  200. messageID: assistantMsg2.id,
  201. sessionID,
  202. type: "text",
  203. text: "The capital of France is Paris.",
  204. })
  205. let messages = yield* session.messages({ sessionID })
  206. expect(messages.length).toBe(4)
  207. const messageIds = messages.map((m) => m.info.id)
  208. expect(messageIds).toContain(userMsg1.id)
  209. expect(messageIds).toContain(userMsg2.id)
  210. expect(messageIds).toContain(assistantMsg1.id)
  211. expect(messageIds).toContain(assistantMsg2.id)
  212. yield* revert.revert({
  213. sessionID,
  214. messageID: userMsg2.id,
  215. })
  216. let sessionInfo = yield* session.get(sessionID)
  217. expect(sessionInfo.revert).toBeDefined()
  218. expect(sessionInfo.revert?.messageID).toBeDefined()
  219. messages = yield* session.messages({ sessionID })
  220. expect(messages.length).toBe(4)
  221. yield* revert.cleanup(sessionInfo)
  222. messages = yield* session.messages({ sessionID })
  223. const remainingIds = messages.map((m) => m.info.id)
  224. expect(messages.length).toBeLessThan(4)
  225. expect(remainingIds).not.toContain(userMsg2.id)
  226. expect(remainingIds).not.toContain(assistantMsg2.id)
  227. sessionInfo = yield* session.get(sessionID)
  228. expect(sessionInfo.revert).toBeUndefined()
  229. yield* session.remove(sessionID)
  230. }),
  231. { git: true },
  232. ),
  233. )
  234. it.live(
  235. "should properly clean up revert state before creating compaction message",
  236. provideTmpdirInstance(
  237. (dir) =>
  238. Effect.gen(function* () {
  239. const session = yield* Session.Service
  240. const revert = yield* SessionRevert.Service
  241. const info = yield* session.create({})
  242. const sessionID = info.id
  243. const userMsg = yield* session.updateMessage({
  244. id: MessageID.ascending(),
  245. role: "user",
  246. sessionID,
  247. agent: "default",
  248. model: {
  249. providerID: ProviderV2.ID.make("openai"),
  250. modelID: ModelV2.ID.make("gpt-4"),
  251. },
  252. time: {
  253. created: Date.now(),
  254. },
  255. })
  256. yield* session.updatePart({
  257. id: PartID.ascending(),
  258. messageID: userMsg.id,
  259. sessionID,
  260. type: "text",
  261. text: "Hello",
  262. })
  263. const assistantMsg: SessionV1.Assistant = {
  264. id: MessageID.ascending(),
  265. role: "assistant",
  266. sessionID,
  267. mode: "default",
  268. agent: "default",
  269. path: {
  270. cwd: dir,
  271. root: dir,
  272. },
  273. cost: 0,
  274. tokens: {
  275. output: 0,
  276. input: 0,
  277. reasoning: 0,
  278. cache: { read: 0, write: 0 },
  279. },
  280. modelID: ModelV2.ID.make("gpt-4"),
  281. providerID: ProviderV2.ID.make("openai"),
  282. parentID: userMsg.id,
  283. time: {
  284. created: Date.now(),
  285. },
  286. finish: "end_turn",
  287. }
  288. yield* session.updateMessage(assistantMsg)
  289. yield* session.updatePart({
  290. id: PartID.ascending(),
  291. messageID: assistantMsg.id,
  292. sessionID,
  293. type: "text",
  294. text: "Hi there!",
  295. })
  296. yield* revert.revert({
  297. sessionID,
  298. messageID: userMsg.id,
  299. })
  300. let sessionInfo = yield* session.get(sessionID)
  301. expect(sessionInfo.revert).toBeDefined()
  302. yield* revert.cleanup(sessionInfo)
  303. sessionInfo = yield* session.get(sessionID)
  304. expect(sessionInfo.revert).toBeUndefined()
  305. const messages = yield* session.messages({ sessionID })
  306. expect(messages.length).toBe(0)
  307. yield* session.remove(sessionID)
  308. }),
  309. { git: true },
  310. ),
  311. )
  312. it.live(
  313. "cleanup with partID removes parts from the revert point onward",
  314. provideTmpdirInstance(
  315. () =>
  316. Effect.gen(function* () {
  317. const session = yield* Session.Service
  318. const revert = yield* SessionRevert.Service
  319. const info = yield* session.create({})
  320. const sid = info.id
  321. const u1 = yield* user(sid)
  322. const p1 = yield* text(sid, u1.id, "first part")
  323. const p2 = yield* tool(sid, u1.id)
  324. yield* text(sid, u1.id, "third part")
  325. yield* session.setRevert({
  326. sessionID: sid,
  327. revert: { messageID: u1.id, partID: p2.id },
  328. summary: { additions: 0, deletions: 0, files: 0 },
  329. })
  330. const state = yield* session.get(sid)
  331. yield* revert.cleanup(state)
  332. const msgs = yield* session.messages({ sessionID: sid })
  333. expect(msgs.length).toBe(1)
  334. expect(msgs[0].parts.length).toBe(1)
  335. expect(msgs[0].parts[0].id).toBe(p1.id)
  336. const cleared = yield* session.get(sid)
  337. expect(cleared.revert).toBeUndefined()
  338. }),
  339. { git: true },
  340. ),
  341. )
  342. it.live(
  343. "cleanup removes messages after revert point but keeps earlier ones",
  344. provideTmpdirInstance(
  345. (dir) =>
  346. Effect.gen(function* () {
  347. const session = yield* Session.Service
  348. const revert = yield* SessionRevert.Service
  349. const info = yield* session.create({})
  350. const sid = info.id
  351. const u1 = yield* user(sid)
  352. yield* text(sid, u1.id, "hello")
  353. const a1 = yield* assistant(sid, u1.id, dir)
  354. yield* text(sid, a1.id, "hi back")
  355. const u2 = yield* user(sid)
  356. yield* text(sid, u2.id, "second question")
  357. const a2 = yield* assistant(sid, u2.id, dir)
  358. yield* text(sid, a2.id, "second answer")
  359. yield* session.setRevert({
  360. sessionID: sid,
  361. revert: { messageID: u2.id },
  362. summary: { additions: 0, deletions: 0, files: 0 },
  363. })
  364. const state = yield* session.get(sid)
  365. yield* revert.cleanup(state)
  366. const msgs = yield* session.messages({ sessionID: sid })
  367. const ids = msgs.map((m) => m.info.id)
  368. expect(ids).toContain(u1.id)
  369. expect(ids).toContain(a1.id)
  370. expect(ids).not.toContain(u2.id)
  371. expect(ids).not.toContain(a2.id)
  372. }),
  373. { git: true },
  374. ),
  375. )
  376. it.live(
  377. "cleanup is a no-op when session has no revert state",
  378. provideTmpdirInstance(
  379. () =>
  380. Effect.gen(function* () {
  381. const session = yield* Session.Service
  382. const revert = yield* SessionRevert.Service
  383. const info = yield* session.create({})
  384. const sid = info.id
  385. const u1 = yield* user(sid)
  386. yield* text(sid, u1.id, "hello")
  387. const state = yield* session.get(sid)
  388. expect(state.revert).toBeUndefined()
  389. yield* revert.cleanup(state)
  390. const msgs = yield* session.messages({ sessionID: sid })
  391. expect(msgs.length).toBe(1)
  392. }),
  393. { git: true },
  394. ),
  395. )
  396. it.live(
  397. "restore messages in sequential order",
  398. provideTmpdirInstance(
  399. (dir) =>
  400. Effect.gen(function* () {
  401. const session = yield* Session.Service
  402. const revert = yield* SessionRevert.Service
  403. const snapshot = yield* Snapshot.Service
  404. yield* write(path.join(dir, "a.txt"), "a0")
  405. yield* write(path.join(dir, "b.txt"), "b0")
  406. yield* write(path.join(dir, "c.txt"), "c0")
  407. const info = yield* session.create({})
  408. const sid = info.id
  409. const turn = Effect.fn("test.turn")(function* (file: string, next: string) {
  410. const u = yield* user(sid)
  411. yield* text(sid, u.id, `${file}:${next}`)
  412. const a = yield* assistant(sid, u.id, dir)
  413. const before = yield* snapshot.track()
  414. if (!before) throw new Error("expected snapshot")
  415. yield* write(path.join(dir, file), next)
  416. const after = yield* snapshot.track()
  417. if (!after) throw new Error("expected snapshot")
  418. const patch = yield* snapshot.patch(before)
  419. yield* session.updatePart({
  420. id: PartID.ascending(),
  421. messageID: a.id,
  422. sessionID: sid,
  423. type: "step-start",
  424. snapshot: before,
  425. })
  426. yield* session.updatePart({
  427. id: PartID.ascending(),
  428. messageID: a.id,
  429. sessionID: sid,
  430. type: "step-finish",
  431. reason: "stop",
  432. snapshot: after,
  433. cost: 0,
  434. tokens,
  435. })
  436. yield* session.updatePart({
  437. id: PartID.ascending(),
  438. messageID: a.id,
  439. sessionID: sid,
  440. type: "patch",
  441. hash: patch.hash,
  442. files: patch.files,
  443. })
  444. return u.id
  445. })
  446. const first = yield* turn("a.txt", "a1")
  447. const second = yield* turn("b.txt", "b2")
  448. const third = yield* turn("c.txt", "c3")
  449. yield* revert.revert({
  450. sessionID: sid,
  451. messageID: first,
  452. })
  453. expect((yield* session.get(sid)).revert?.messageID).toBe(first)
  454. expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
  455. expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
  456. expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
  457. yield* revert.revert({
  458. sessionID: sid,
  459. messageID: second,
  460. })
  461. expect((yield* session.get(sid)).revert?.messageID).toBe(second)
  462. expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
  463. expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
  464. expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
  465. yield* revert.revert({
  466. sessionID: sid,
  467. messageID: third,
  468. })
  469. expect((yield* session.get(sid)).revert?.messageID).toBe(third)
  470. expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
  471. expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
  472. expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
  473. yield* revert.unrevert({
  474. sessionID: sid,
  475. })
  476. expect((yield* session.get(sid)).revert).toBeUndefined()
  477. expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
  478. expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
  479. expect(yield* read(path.join(dir, "c.txt"))).toBe("c3")
  480. }),
  481. { git: true },
  482. ),
  483. )
  484. it.live(
  485. "restore same file in sequential order",
  486. provideTmpdirInstance(
  487. (dir) =>
  488. Effect.gen(function* () {
  489. const session = yield* Session.Service
  490. const revert = yield* SessionRevert.Service
  491. const snapshot = yield* Snapshot.Service
  492. yield* write(path.join(dir, "a.txt"), "a0")
  493. const info = yield* session.create({})
  494. const sid = info.id
  495. const turn = Effect.fn("test.turnSame")(function* (next: string) {
  496. const u = yield* user(sid)
  497. yield* text(sid, u.id, `a.txt:${next}`)
  498. const a = yield* assistant(sid, u.id, dir)
  499. const before = yield* snapshot.track()
  500. if (!before) throw new Error("expected snapshot")
  501. yield* write(path.join(dir, "a.txt"), next)
  502. const after = yield* snapshot.track()
  503. if (!after) throw new Error("expected snapshot")
  504. const patch = yield* snapshot.patch(before)
  505. yield* session.updatePart({
  506. id: PartID.ascending(),
  507. messageID: a.id,
  508. sessionID: sid,
  509. type: "step-start",
  510. snapshot: before,
  511. })
  512. yield* session.updatePart({
  513. id: PartID.ascending(),
  514. messageID: a.id,
  515. sessionID: sid,
  516. type: "step-finish",
  517. reason: "stop",
  518. snapshot: after,
  519. cost: 0,
  520. tokens,
  521. })
  522. yield* session.updatePart({
  523. id: PartID.ascending(),
  524. messageID: a.id,
  525. sessionID: sid,
  526. type: "patch",
  527. hash: patch.hash,
  528. files: patch.files,
  529. })
  530. return u.id
  531. })
  532. const first = yield* turn("a1")
  533. const second = yield* turn("a2")
  534. const third = yield* turn("a3")
  535. expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
  536. yield* revert.revert({
  537. sessionID: sid,
  538. messageID: first,
  539. })
  540. expect((yield* session.get(sid)).revert?.messageID).toBe(first)
  541. expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
  542. yield* revert.revert({
  543. sessionID: sid,
  544. messageID: second,
  545. })
  546. expect((yield* session.get(sid)).revert?.messageID).toBe(second)
  547. expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
  548. yield* revert.revert({
  549. sessionID: sid,
  550. messageID: third,
  551. })
  552. expect((yield* session.get(sid)).revert?.messageID).toBe(third)
  553. expect(yield* read(path.join(dir, "a.txt"))).toBe("a2")
  554. yield* revert.unrevert({
  555. sessionID: sid,
  556. })
  557. expect((yield* session.get(sid)).revert).toBeUndefined()
  558. expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
  559. }),
  560. { git: true },
  561. ),
  562. )
  563. })