|
|
@@ -0,0 +1,278 @@
|
|
|
+/** @jsxImportSource @opentui/solid */
|
|
|
+import { expect, test } from "bun:test"
|
|
|
+import { Global } from "@opencode-ai/core/global"
|
|
|
+import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
|
|
+import { tmpdir } from "../../../fixture/fixture"
|
|
|
+import { json, mount, wait } from "./sync-fixture"
|
|
|
+
|
|
|
+const sessionID = "ses_hydration_race"
|
|
|
+const messageID = "msg_hydration_race"
|
|
|
+const partID = "prt_hydration_race"
|
|
|
+const session = {
|
|
|
+ id: sessionID,
|
|
|
+ title: "race",
|
|
|
+ time: { created: 0, updated: 0 },
|
|
|
+ version: "1.15.13",
|
|
|
+ directory: "/tmp/opencode/packages/opencode",
|
|
|
+}
|
|
|
+const assistant = {
|
|
|
+ id: messageID,
|
|
|
+ sessionID,
|
|
|
+ role: "assistant" as const,
|
|
|
+ agent: "build",
|
|
|
+ modelID: "model",
|
|
|
+ providerID: "test",
|
|
|
+ mode: "build",
|
|
|
+ parentID: "msg_user",
|
|
|
+ path: { cwd: session.directory, root: session.directory },
|
|
|
+ cost: 0,
|
|
|
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
|
+ time: { created: 1, completed: 2 },
|
|
|
+}
|
|
|
+
|
|
|
+function global(payload: GlobalEvent["payload"]): GlobalEvent {
|
|
|
+ return { directory: "/tmp/other", project: "proj_test", payload }
|
|
|
+}
|
|
|
+
|
|
|
+test("stale session hydration does not overwrite live message parts", async () => {
|
|
|
+ const previous = Global.Path.state
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ Global.Path.state = tmp.path
|
|
|
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
|
|
|
+
|
|
|
+ let resolveMessages!: (response: Response) => void
|
|
|
+ const messages = new Promise<Response>((resolve) => {
|
|
|
+ resolveMessages = resolve
|
|
|
+ })
|
|
|
+ let requested = false
|
|
|
+ const { app, emit, sync } = await mount((url) => {
|
|
|
+ if (url.pathname === `/session/${sessionID}`) return json(session)
|
|
|
+ if (url.pathname === `/session/${sessionID}/message`) {
|
|
|
+ requested = true
|
|
|
+ return messages
|
|
|
+ }
|
|
|
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
|
|
|
+ return undefined
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ const hydrate = sync.session.sync(sessionID)
|
|
|
+ await wait(() => requested)
|
|
|
+ emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
|
|
|
+ emit(
|
|
|
+ global({
|
|
|
+ id: "evt_part",
|
|
|
+ type: "message.part.updated",
|
|
|
+ properties: {
|
|
|
+ sessionID,
|
|
|
+ time: 2,
|
|
|
+ part: { id: partID, sessionID, messageID, type: "text", text: "visible live content" },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ await wait(() => sync.data.part[messageID]?.[0]?.type === "text")
|
|
|
+
|
|
|
+ resolveMessages(
|
|
|
+ json([
|
|
|
+ {
|
|
|
+ info: assistant,
|
|
|
+ parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }],
|
|
|
+ },
|
|
|
+ ]),
|
|
|
+ )
|
|
|
+ await hydrate
|
|
|
+
|
|
|
+ expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible live content" })
|
|
|
+ } finally {
|
|
|
+ app.renderer.destroy()
|
|
|
+ Global.Path.state = previous
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+test("orphan live deltas do not suppress hydrated parts", async () => {
|
|
|
+ const previous = Global.Path.state
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ Global.Path.state = tmp.path
|
|
|
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
|
|
|
+
|
|
|
+ let resolveMessages!: (response: Response) => void
|
|
|
+ const messages = new Promise<Response>((resolve) => {
|
|
|
+ resolveMessages = resolve
|
|
|
+ })
|
|
|
+ let requested = false
|
|
|
+ const { app, emit, sync } = await mount((url) => {
|
|
|
+ if (url.pathname === `/session/${sessionID}`) return json(session)
|
|
|
+ if (url.pathname === `/session/${sessionID}/message`) {
|
|
|
+ requested = true
|
|
|
+ return messages
|
|
|
+ }
|
|
|
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
|
|
|
+ return undefined
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ const hydrate = sync.session.sync(sessionID)
|
|
|
+ await wait(() => requested)
|
|
|
+ emit(
|
|
|
+ global({
|
|
|
+ id: "evt_delta",
|
|
|
+ type: "message.part.delta",
|
|
|
+ properties: { sessionID, messageID, partID, field: "text", delta: "ignored until part exists" },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ resolveMessages(
|
|
|
+ json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "hydrated" }] }]),
|
|
|
+ )
|
|
|
+ await hydrate
|
|
|
+
|
|
|
+ expect(sync.data.part[messageID][0]).toMatchObject({ text: "hydrated" })
|
|
|
+ } finally {
|
|
|
+ app.renderer.destroy()
|
|
|
+ Global.Path.state = previous
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+test("hydration does not clear text streamed before it starts", async () => {
|
|
|
+ const previous = Global.Path.state
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ Global.Path.state = tmp.path
|
|
|
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
|
|
|
+
|
|
|
+ let resolveMessages!: (response: Response) => void
|
|
|
+ const messages = new Promise<Response>((resolve) => {
|
|
|
+ resolveMessages = resolve
|
|
|
+ })
|
|
|
+ let requested = false
|
|
|
+ const { app, emit, sync } = await mount((url) => {
|
|
|
+ if (url.pathname === `/session/${sessionID}`) return json(session)
|
|
|
+ if (url.pathname === `/session/${sessionID}/message`) {
|
|
|
+ requested = true
|
|
|
+ return messages
|
|
|
+ }
|
|
|
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
|
|
|
+ return undefined
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
|
|
|
+ emit(
|
|
|
+ global({
|
|
|
+ id: "evt_part",
|
|
|
+ type: "message.part.updated",
|
|
|
+ properties: {
|
|
|
+ sessionID,
|
|
|
+ time: 1,
|
|
|
+ part: { id: partID, sessionID, messageID, type: "text", text: "" },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ emit(
|
|
|
+ global({
|
|
|
+ id: "evt_delta",
|
|
|
+ type: "message.part.delta",
|
|
|
+ properties: { sessionID, messageID, partID, field: "text", delta: "visible streamed content" },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ await wait(() => sync.data.part[messageID]?.[0]?.type === "text" && sync.data.part[messageID][0].text !== "")
|
|
|
+ const hydrate = sync.session.sync(sessionID)
|
|
|
+ await wait(() => requested)
|
|
|
+ resolveMessages(json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }] }]))
|
|
|
+ await hydrate
|
|
|
+
|
|
|
+ expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible streamed content" })
|
|
|
+ } finally {
|
|
|
+ app.renderer.destroy()
|
|
|
+ Global.Path.state = previous
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+test("live messages merged during hydration retain the 100 message window", async () => {
|
|
|
+ const previous = Global.Path.state
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ Global.Path.state = tmp.path
|
|
|
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
|
|
|
+
|
|
|
+ let resolveMessages!: (response: Response) => void
|
|
|
+ const messages = new Promise<Response>((resolve) => {
|
|
|
+ resolveMessages = resolve
|
|
|
+ })
|
|
|
+ let requested = false
|
|
|
+ const { app, emit, sync } = await mount((url) => {
|
|
|
+ if (url.pathname === `/session/${sessionID}`) return json(session)
|
|
|
+ if (url.pathname === `/session/${sessionID}/message`) {
|
|
|
+ requested = true
|
|
|
+ return messages
|
|
|
+ }
|
|
|
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
|
|
|
+ return undefined
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ const hydrate = sync.session.sync(sessionID)
|
|
|
+ await wait(() => requested)
|
|
|
+ const live = { ...assistant, id: "msg_z_live" }
|
|
|
+ emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } }))
|
|
|
+ await wait(() => sync.data.message[sessionID]?.some((message) => message.id === live.id) ?? false)
|
|
|
+ resolveMessages(
|
|
|
+ json(
|
|
|
+ Array.from({ length: 100 }, (_, index) => {
|
|
|
+ const id = `msg_${String(index).padStart(3, "0")}`
|
|
|
+ return {
|
|
|
+ info: { ...assistant, id },
|
|
|
+ parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
|
|
|
+ }
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ await hydrate
|
|
|
+
|
|
|
+ expect(sync.data.message[sessionID]).toHaveLength(100)
|
|
|
+ expect(sync.data.message[sessionID].at(-1)?.id).toBe(live.id)
|
|
|
+ expect(sync.data.message[sessionID].some((message) => message.id === "msg_000")).toBe(false)
|
|
|
+ expect(sync.data.part.msg_000).toBeUndefined()
|
|
|
+ } finally {
|
|
|
+ app.renderer.destroy()
|
|
|
+ Global.Path.state = previous
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+test("a message removed during hydration does not regain stale parts", async () => {
|
|
|
+ const previous = Global.Path.state
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ Global.Path.state = tmp.path
|
|
|
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
|
|
|
+
|
|
|
+ let resolveMessages!: (response: Response) => void
|
|
|
+ const messages = new Promise<Response>((resolve) => {
|
|
|
+ resolveMessages = resolve
|
|
|
+ })
|
|
|
+ let requested = false
|
|
|
+ const { app, emit, sync } = await mount((url) => {
|
|
|
+ if (url.pathname === `/session/${sessionID}`) return json(session)
|
|
|
+ if (url.pathname === `/session/${sessionID}/message`) {
|
|
|
+ requested = true
|
|
|
+ return messages
|
|
|
+ }
|
|
|
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
|
|
|
+ return undefined
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
|
|
|
+ await wait(() => sync.data.message[sessionID]?.length === 1)
|
|
|
+ const hydrate = sync.session.sync(sessionID)
|
|
|
+ await wait(() => requested)
|
|
|
+ emit(global({ id: "evt_removed", type: "message.removed", properties: { sessionID, messageID } }))
|
|
|
+ await wait(() => sync.data.message[sessionID]?.length === 0)
|
|
|
+ resolveMessages(
|
|
|
+ json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "stale" }] }]),
|
|
|
+ )
|
|
|
+ await hydrate
|
|
|
+
|
|
|
+ expect(sync.data.message[sessionID]).toEqual([])
|
|
|
+ expect(sync.data.part[messageID]).toBeUndefined()
|
|
|
+ } finally {
|
|
|
+ app.renderer.destroy()
|
|
|
+ Global.Path.state = previous
|
|
|
+ }
|
|
|
+})
|