sync-live-hydration.test.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. /** @jsxImportSource @opentui/solid */
  2. import { expect, test } from "bun:test"
  3. import type { GlobalEvent } from "@kirincode-ai/sdk/v2"
  4. import { tmpdir } from "../../../fixture/fixture"
  5. import { json, mount, wait } from "./sync-fixture"
  6. const sessionID = "ses_hydration_race"
  7. const messageID = "msg_hydration_race"
  8. const partID = "prt_hydration_race"
  9. const session = {
  10. id: sessionID,
  11. title: "race",
  12. time: { created: 0, updated: 0 },
  13. version: "1.15.13",
  14. directory: "/tmp/kirincode/packages/opencode",
  15. }
  16. const assistant = {
  17. id: messageID,
  18. sessionID,
  19. role: "assistant" as const,
  20. agent: "build",
  21. modelID: "model",
  22. providerID: "test",
  23. mode: "build",
  24. parentID: "msg_user",
  25. path: { cwd: session.directory, root: session.directory },
  26. cost: 0,
  27. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  28. time: { created: 1, completed: 2 },
  29. }
  30. function global(payload: GlobalEvent["payload"]): GlobalEvent {
  31. return { directory: "/tmp/other", project: "proj_test", payload }
  32. }
  33. test("stale session hydration does not overwrite live message parts", async () => {
  34. await using tmp = await tmpdir()
  35. await Bun.write(`${tmp.path}/kv.json`, "{}")
  36. let resolveMessages!: (response: Response) => void
  37. const messages = new Promise<Response>((resolve) => {
  38. resolveMessages = resolve
  39. })
  40. let requested = false
  41. const { app, emit, sync } = await mount((url) => {
  42. if (url.pathname === `/session/${sessionID}`) return json(session)
  43. if (url.pathname === `/session/${sessionID}/message`) {
  44. requested = true
  45. return messages
  46. }
  47. if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
  48. return undefined
  49. }, tmp.path)
  50. try {
  51. const hydrate = sync.session.sync(sessionID)
  52. await wait(() => requested)
  53. emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
  54. emit(
  55. global({
  56. id: "evt_part",
  57. type: "message.part.updated",
  58. properties: {
  59. sessionID,
  60. time: 2,
  61. part: { id: partID, sessionID, messageID, type: "text", text: "visible live content" },
  62. },
  63. }),
  64. )
  65. await wait(() => sync.data.part[messageID]?.[0]?.type === "text")
  66. resolveMessages(
  67. json([
  68. {
  69. info: assistant,
  70. parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }],
  71. },
  72. ]),
  73. )
  74. await hydrate
  75. expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible live content" })
  76. } finally {
  77. app.renderer.destroy()
  78. }
  79. })
  80. test("orphan live deltas do not suppress hydrated parts", async () => {
  81. await using tmp = await tmpdir()
  82. await Bun.write(`${tmp.path}/kv.json`, "{}")
  83. let resolveMessages!: (response: Response) => void
  84. const messages = new Promise<Response>((resolve) => {
  85. resolveMessages = resolve
  86. })
  87. let requested = false
  88. const { app, emit, sync } = await mount((url) => {
  89. if (url.pathname === `/session/${sessionID}`) return json(session)
  90. if (url.pathname === `/session/${sessionID}/message`) {
  91. requested = true
  92. return messages
  93. }
  94. if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
  95. return undefined
  96. }, tmp.path)
  97. try {
  98. const hydrate = sync.session.sync(sessionID)
  99. await wait(() => requested)
  100. emit(
  101. global({
  102. id: "evt_delta",
  103. type: "message.part.delta",
  104. properties: { sessionID, messageID, partID, field: "text", delta: "ignored until part exists" },
  105. }),
  106. )
  107. resolveMessages(
  108. json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "hydrated" }] }]),
  109. )
  110. await hydrate
  111. expect(sync.data.part[messageID][0]).toMatchObject({ text: "hydrated" })
  112. } finally {
  113. app.renderer.destroy()
  114. }
  115. })
  116. test("hydration does not clear text streamed before it starts", async () => {
  117. await using tmp = await tmpdir()
  118. await Bun.write(`${tmp.path}/kv.json`, "{}")
  119. let resolveMessages!: (response: Response) => void
  120. const messages = new Promise<Response>((resolve) => {
  121. resolveMessages = resolve
  122. })
  123. let requested = false
  124. const { app, emit, sync } = await mount((url) => {
  125. if (url.pathname === `/session/${sessionID}`) return json(session)
  126. if (url.pathname === `/session/${sessionID}/message`) {
  127. requested = true
  128. return messages
  129. }
  130. if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
  131. return undefined
  132. }, tmp.path)
  133. try {
  134. emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
  135. emit(
  136. global({
  137. id: "evt_part",
  138. type: "message.part.updated",
  139. properties: {
  140. sessionID,
  141. time: 1,
  142. part: { id: partID, sessionID, messageID, type: "text", text: "" },
  143. },
  144. }),
  145. )
  146. emit(
  147. global({
  148. id: "evt_delta",
  149. type: "message.part.delta",
  150. properties: { sessionID, messageID, partID, field: "text", delta: "visible streamed content" },
  151. }),
  152. )
  153. await wait(() => sync.data.part[messageID]?.[0]?.type === "text" && sync.data.part[messageID][0].text !== "")
  154. const hydrate = sync.session.sync(sessionID)
  155. await wait(() => requested)
  156. resolveMessages(json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }] }]))
  157. await hydrate
  158. expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible streamed content" })
  159. } finally {
  160. app.renderer.destroy()
  161. }
  162. })
  163. test("live messages merged during hydration retain the 100 message window", async () => {
  164. await using tmp = await tmpdir()
  165. await Bun.write(`${tmp.path}/kv.json`, "{}")
  166. let resolveMessages!: (response: Response) => void
  167. const messages = new Promise<Response>((resolve) => {
  168. resolveMessages = resolve
  169. })
  170. let requested = false
  171. const { app, emit, sync } = await mount((url) => {
  172. if (url.pathname === `/session/${sessionID}`) return json(session)
  173. if (url.pathname === `/session/${sessionID}/message`) {
  174. requested = true
  175. return messages
  176. }
  177. if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
  178. return undefined
  179. }, tmp.path)
  180. try {
  181. const hydrate = sync.session.sync(sessionID)
  182. await wait(() => requested)
  183. const live = { ...assistant, id: "msg_z_live" }
  184. emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } }))
  185. await wait(() => sync.data.message[sessionID]?.some((message) => message.id === live.id) ?? false)
  186. resolveMessages(
  187. json(
  188. Array.from({ length: 100 }, (_, index) => {
  189. const id = `msg_${String(index).padStart(3, "0")}`
  190. return {
  191. info: { ...assistant, id },
  192. parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
  193. }
  194. }),
  195. ),
  196. )
  197. await hydrate
  198. expect(sync.data.message[sessionID]).toHaveLength(100)
  199. expect(sync.data.message[sessionID].at(-1)?.id).toBe(live.id)
  200. expect(sync.data.message[sessionID].some((message) => message.id === "msg_000")).toBe(false)
  201. expect(sync.data.part.msg_000).toBeUndefined()
  202. } finally {
  203. app.renderer.destroy()
  204. }
  205. })
  206. test("a message removed during hydration does not regain stale parts", async () => {
  207. await using tmp = await tmpdir()
  208. await Bun.write(`${tmp.path}/kv.json`, "{}")
  209. let resolveMessages!: (response: Response) => void
  210. const messages = new Promise<Response>((resolve) => {
  211. resolveMessages = resolve
  212. })
  213. let requested = false
  214. const { app, emit, sync } = await mount((url) => {
  215. if (url.pathname === `/session/${sessionID}`) return json(session)
  216. if (url.pathname === `/session/${sessionID}/message`) {
  217. requested = true
  218. return messages
  219. }
  220. if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
  221. return undefined
  222. }, tmp.path)
  223. try {
  224. emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
  225. await wait(() => sync.data.message[sessionID]?.length === 1)
  226. const hydrate = sync.session.sync(sessionID)
  227. await wait(() => requested)
  228. emit(global({ id: "evt_removed", type: "message.removed", properties: { sessionID, messageID } }))
  229. await wait(() => sync.data.message[sessionID]?.length === 0)
  230. resolveMessages(
  231. json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "stale" }] }]),
  232. )
  233. await hydrate
  234. expect(sync.data.message[sessionID]).toEqual([])
  235. expect(sync.data.part[messageID]).toBeUndefined()
  236. } finally {
  237. app.renderer.destroy()
  238. }
  239. })