diff --git a/packages/opencode/src/session/file-change.ts b/packages/opencode/src/session/file-change.ts new file mode 100644 index 000000000000..81676576d7ad --- /dev/null +++ b/packages/opencode/src/session/file-change.ts @@ -0,0 +1,72 @@ +import path from "path" +import { Effect } from "effect" +import { Storage } from "@/storage/storage" +import { SessionID } from "./schema" +import { Hash } from "@opencode-ai/core/util/hash" + +export const metadataKey = "__opencode_file_baselines" +const maxBytes = 1024 * 1024 + +export interface Input { + file: string + existed: boolean + content: string + bom: boolean +} + +export interface Baseline extends Omit { + path: string + content: string | undefined +} + +export const persist = Effect.fn("SessionFileChange.persist")(function* (input: { + storage: Storage.Interface + sessionID: SessionID + directory: string + metadata: Record +}) { + const raw = input.metadata[metadataKey] + const metadata = { ...input.metadata } + delete metadata[metadataKey] + if (!Array.isArray(raw)) return metadata + + const baselines = raw.flatMap((item): Baseline[] => { + if (!item || typeof item !== "object") return [] + const record = item as Record + if ( + typeof record.file !== "string" || + typeof record.existed !== "boolean" || + typeof record.content !== "string" || + typeof record.bom !== "boolean" + ) { + return [] + } + const relative = path.relative(input.directory, record.file).replaceAll("\\", "/") + if (!relative || relative === ".." || relative.startsWith("../")) return [] + return [ + { + path: relative, + existed: record.existed, + content: Buffer.byteLength(record.content) <= maxBytes ? record.content : undefined, + bom: record.bom, + }, + ] + }) + yield* Effect.forEach(baselines, (baseline) => { + const key = ["session_file", input.sessionID, Hash.fast(baseline.path)] + return input.storage.read(key).pipe( + Effect.catch(() => input.storage.write(key, baseline).pipe(Effect.orDie, Effect.as(baseline))), + Effect.ignore, + ) + }) + return metadata +}) + +export const list = Effect.fn("SessionFileChange.list")(function* (storage: Storage.Interface, sessionID: SessionID) { + const keys = yield* storage.list(["session_file", sessionID]).pipe(Effect.orDie) + return yield* Effect.forEach(keys, (key) => storage.read(key).pipe(Effect.orDie), { + concurrency: "unbounded", + }) +}) + +export * as SessionFileChange from "./file-change" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..ef054d2b5d33 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -25,6 +25,8 @@ import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" import { Usage, type LLMEvent } from "@opencode-ai/llm" +import { Storage } from "@/storage/storage" +import { SessionFileChange } from "./file-change" const DOOM_LOOP_THRESHOLD = 3 export type Result = "compact" | "stop" | "continue" @@ -88,6 +90,7 @@ const layer = Layer.effect( const llm = yield* LLM.Service const permission = yield* Permission.Service const plugin = yield* Plugin.Service + const storage = yield* Storage.Service const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service @@ -168,13 +171,19 @@ const layer = Layer.effect( ) { const match = yield* readToolCall(toolCallID) if (!match || match.part.state.status !== "running") return + const metadata = yield* SessionFileChange.persist({ + storage, + sessionID: match.part.sessionID, + directory: (yield* session.get(match.part.sessionID).pipe(Effect.orDie)).directory, + metadata: output.metadata, + }) yield* session.updatePart({ ...match.part, state: { status: "completed", input: match.part.state.input, output: output.output, - metadata: output.metadata, + metadata, title: output.title, time: { start: match.part.state.time.start, end: Date.now() }, attachments: output.attachments, @@ -712,6 +721,7 @@ export const node = LayerNode.make({ Image.node, EventV2Bridge.node, Database.node, + Storage.node, ], }) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 6484730d0bd5..f5918f36a9e4 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,11 +1,16 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer, Context, Schema } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "@/snapshot" import { Session } from "./session" import { SessionID, MessageID } from "./schema" import { Config } from "@/config/config" +import path from "path" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { createTwoFilesPatch, diffLines } from "diff" +import { Storage } from "@/storage/storage" +import { SessionFileChange } from "./file-change" +import * as Bom from "@/util/bom" function unquoteGitPath(input: string) { if (!input.startsWith('"')) return input @@ -76,8 +81,9 @@ const layer = Layer.effect( Effect.gen(function* () { const sessions = yield* Session.Service const snapshot = yield* Snapshot.Service - const events = yield* EventV2Bridge.Service const config = yield* Config.Service + const fs = yield* FSUtil.Service + const storage = yield* Storage.Service const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) { let from: string | undefined @@ -103,15 +109,6 @@ const layer = Layer.effect( sessionID: SessionID messageID: MessageID }) { - yield* sessions.setSummary({ - sessionID: input.sessionID, - summary: { - additions: 0, - deletions: 0, - files: 0, - }, - }) - yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: [] }) if ((yield* config.get()).snapshot === false) return const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) if (!all.length) return @@ -127,10 +124,29 @@ const layer = Layer.effect( }) const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { - if (!input.messageID) return [] - const message = (yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find( - (item) => item.info.id === input.messageID, - ) + const messages = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + if (!input.messageID) { + const directory = (yield* sessions.get(input.sessionID).pipe(Effect.orDie)).directory + const tracked = yield* SessionFileChange.list(storage, input.sessionID) + if (tracked.length) { + const unique = new Map() + for (const baseline of tracked) { + if (!unique.has(baseline.path)) unique.set(baseline.path, baseline) + } + const current = yield* Effect.forEach(unique.values(), (baseline) => + trackedDiff(fs, baseline, directory), + ).pipe(Effect.map((items) => items.flatMap((item) => (item ? [item] : [])))) + const paths = new Set(unique.keys()) + return [ + ...editedFiles(messages, directory).filter((item) => item.file && !paths.has(item.file)), + ...current, + ].toSorted((a, b) => (a.file ?? "").localeCompare(b.file ?? "")) + } + const snapshotDiff = (yield* config.get()).snapshot === false ? [] : yield* computeDiff({ messages }) + if (snapshotDiff.length) return snapshotDiff + return editedFiles(messages, directory) + } + const message = messages.find((item) => item.info.id === input.messageID) if (!message || message.info.role !== "user") return [] const diffs = message.info.summary?.diffs ?? [] return diffs.map((item) => { @@ -154,7 +170,71 @@ export type DiffInput = Schema.Schema.Type export const node = LayerNode.make({ service: Service, layer: layer, - deps: [Session.node, Snapshot.node, EventV2Bridge.node, Config.node], + deps: [Session.node, Snapshot.node, Config.node, FSUtil.node, Storage.node], +}) + +const trackedDiff = Effect.fnUntraced(function* ( + fs: FSUtil.Interface, + baseline: SessionFileChange.Baseline, + directory: string, +) { + if (baseline.content === undefined) return + const file = path.join(directory, baseline.path) + const exists = yield* fs.existsSafe(file) + const current = exists ? yield* Bom.readFile(fs, file).pipe(Effect.catch(() => Effect.succeed(undefined))) : undefined + if (exists && current === undefined) return + const content = current?.text ?? "" + const bom = current?.bom ?? false + if (baseline.existed === exists && baseline.content === content && baseline.bom === bom) return + const changes = diffLines(baseline.content, content) + return { + file: baseline.path, + patch: createTwoFilesPatch(file, file, baseline.content, content), + additions: changes.reduce((sum, change) => sum + (change.added ? change.count : 0), 0), + deletions: changes.reduce((sum, change) => sum + (change.removed ? change.count : 0), 0), + status: !baseline.existed ? "added" : !exists ? "deleted" : "modified", + } satisfies Snapshot.FileDiff }) +function editedFiles(messages: SessionV1.WithParts[], directory: string) { + const files = new Map() + for (const part of messages.flatMap((message) => message.parts)) { + if (part.type !== "tool" || part.state.status !== "completed") continue + const metadata = part.state.metadata + if (!metadata || typeof metadata !== "object") continue + const records = Array.isArray(metadata.filediffs) + ? metadata.filediffs + : metadata.filediff + ? [metadata.filediff] + : Array.isArray(metadata.files) + ? metadata.files + : [] + for (const record of records) { + if (!record || typeof record !== "object") continue + const source = record as Record + const file = [source.file, source.filePath, source.movePath, source.relativePath].find( + (value): value is string => typeof value === "string", + ) + if (!file) continue + const relative = path.isAbsolute(file) ? path.relative(directory, file).replaceAll("\\", "/") : file + if (relative.startsWith("../") || relative === "..") continue + const display = relative + const previous = files.get(display) + files.set(display, { + file: display, + patch: typeof source.patch === "string" ? source.patch : previous?.patch, + additions: (previous?.additions ?? 0) + (typeof source.additions === "number" ? source.additions : 0), + deletions: (previous?.deletions ?? 0) + (typeof source.deletions === "number" ? source.deletions : 0), + status: + source.status === "added" || source.type === "add" + ? "added" + : source.status === "deleted" || source.type === "delete" + ? "deleted" + : "modified", + }) + } + } + return [...files.values()] +} + export * as SessionSummary from "./summary" diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index f9201be8a7db..eccb1c42f938 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -14,6 +14,7 @@ import DESCRIPTION from "./apply_patch.txt" import { FileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../format" import * as Bom from "@/util/bom" +import { SessionFileChange } from "@/session/file-change" export const Parameters = Schema.Struct({ patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), @@ -141,6 +142,11 @@ export const ApplyPatchTool = Tool.define( const movePath = hunk.move_path ? path.resolve(instance.directory, hunk.move_path) : undefined yield* assertExternalDirectoryEffect(ctx, movePath) + if (movePath && (yield* afs.existsSafe(movePath))) { + return yield* Effect.fail( + new Error(`apply_patch verification failed: Move destination exists: ${movePath}`), + ) + } fileChanges.push({ filePath, @@ -297,6 +303,15 @@ export const ApplyPatchTool = Tool.define( metadata: { diff: totalDiff, files, + [SessionFileChange.metadataKey]: fileChanges.flatMap((change) => [ + { + file: change.filePath, + existed: change.type !== "add", + content: change.oldContent, + bom: change.type === "add" ? false : change.bom, + }, + ...(change.movePath ? [{ file: change.movePath, existed: false, content: "", bom: false }] : []), + ]), diagnostics, }, output, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a92e4720c0fc..235bf16b75cc 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -18,6 +18,7 @@ import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" import { FSUtil } from "@opencode-ai/core/fs-util" import * as Bom from "@/util/bom" +import { SessionFileChange } from "@/session/file-change" function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") @@ -85,10 +86,12 @@ export const EditTool = Tool.define( let diff = "" let contentOld = "" let contentNew = "" + let existed = false + let bom = false yield* lock(filePath).withPermits(1)( Effect.gen(function* () { if (params.oldString === "") { - const existed = yield* afs.existsSafe(filePath) + existed = yield* afs.existsSafe(filePath) if (existed) { throw new Error( "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.", @@ -96,6 +99,7 @@ export const EditTool = Tool.define( } const next = Bom.split(params.newString) const desiredBom = next.bom + bom = false contentOld = "" contentNew = next.text diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) @@ -124,6 +128,8 @@ export const EditTool = Tool.define( if (!info) throw new Error(`File ${filePath} not found`) if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) const source = yield* Bom.readFile(afs, filePath) + existed = true + bom = source.bom contentOld = source.text const ending = detectLineEnding(contentOld) @@ -205,6 +211,7 @@ export const EditTool = Tool.define( diagnostics, diff, filediff, + [SessionFileChange.metadataKey]: [{ file: filePath, existed, content: contentOld, bom }], }, title: `${path.relative(instance.worktree, filePath)}`, output, diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 37be6d8c47bc..79a11103b3dd 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -3,7 +3,7 @@ import * as path from "path" import { Effect } from "effect" import * as Tool from "./tool" import { LSP } from "@/lsp/lsp" -import { createTwoFilesPatch } from "diff" +import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./write.txt" import { EventV2Bridge } from "@/event-v2-bridge" import { FileSystem } from "@opencode-ai/core/filesystem" @@ -14,6 +14,7 @@ import { InstanceState } from "@/effect/instance-state" import { trimDiff } from "./edit" import { assertExternalDirectoryEffect } from "./external-directory" import * as Bom from "@/util/bom" +import { SessionFileChange } from "@/session/file-change" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -49,8 +50,15 @@ export const WriteTool = Tool.define( const desiredBom = source.bom || next.bom const contentOld = source.text const contentNew = next.text - const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) + const changes = diffLines(contentOld, contentNew) + const filediff = { + file: filepath, + patch: diff, + additions: changes.reduce((sum, change) => sum + (change.added ? change.count : 0), 0), + deletions: changes.reduce((sum, change) => sum + (change.removed ? change.count : 0), 0), + status: exists ? ("modified" as const) : ("added" as const), + } yield* ctx.ask({ permission: "edit", patterns: [path.relative(instance.worktree, filepath)], @@ -95,6 +103,10 @@ export const WriteTool = Tool.define( diagnostics, filepath, exists: exists, + filediff, + [SessionFileChange.metadataKey]: [ + { file: filepath, existed: exists, content: contentOld, bom: source.bom }, + ], }, output, } diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 1265237840f3..ee1af1930437 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -5,7 +5,7 @@ * processes the tool call and executes the tool (e.g. apply_patch) before * the processor's start-step handler can capture a pre-tool snapshot. * Both the "before" and "after" snapshots end up with the same git tree - * hash, so computeDiff returns empty and the session summary shows 0 files. + * hash, so computeDiff returns empty. * * This is a real bug: the snapshot system assumes it can capture state * before tools run by hooking into start-step, but the AI SDK executes @@ -183,7 +183,106 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => yield* Effect.sleep("100 millis") } expect(diff.length).toBeGreaterThan(0) + expect((yield* summary.diff({ sessionID: session.id })).length).toBeGreaterThan(0) + + yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("remove the file"), "bash", { + command: `rm ${filePath}`, + }) + yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "remove the file" }], + }) + yield* prompt.loop({ sessionID: session.id }) + + expect(yield* summary.diff({ sessionID: session.id })).toEqual([]) }), { git: true, config: providerCfg }, ), ) + +it.live("session diff includes explicit edits and excludes build artifacts without git", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ dir, llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const summary = yield* SessionSummary.Service + const session = yield* sessions.create({ + title: "non-git edited files", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create source files"), "write", { + filePath: path.join(dir, "CMakeLists.txt"), + content: "project(hello)\n", + }) + yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("Wrote file successfully"), "write", { + filePath: path.join(dir, "main.cpp"), + content: "int main() { return 0; }\n", + }) + yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("main.cpp"), "bash", { + command: `mkdir -p ${path.join(dir, "build/CMakeFiles")} && echo artifact > ${path.join(dir, "build/CMakeFiles/output.o")}`, + }) + yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "create source files" }], + }) + yield* prompt.loop({ sessionID: session.id }) + + const files = (yield* summary.diff({ sessionID: session.id })).map((item) => item.file) + expect(files).toEqual(["CMakeLists.txt", "main.cpp"]) + expect( + (yield* sessions.messages({ sessionID: session.id })) + .flatMap((message) => message.parts) + .filter((part) => part.type === "tool") + .some((part) => JSON.stringify(part.state).includes("__opencode_file_baselines")), + ).toBe(false) + }), + { config: providerCfg }, + ), +) + +it.live("session diff removes files created and deleted by later agent steps", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ dir, llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const summary = yield* SessionSummary.Service + const session = yield* sessions.create({ + title: "create then delete", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const gitignore = path.join(dir, ".gitignore") + + yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create files"), "write", { + filePath: gitignore, + content: "build/\n", + }) + yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("Wrote file successfully"), "write", { + filePath: path.join(dir, "main.cpp"), + content: "int main() { return 0; }\n", + }) + yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("main.cpp"), "bash", { + command: `rm ${gitignore}`, + }) + yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "create files" }], + }) + yield* prompt.loop({ sessionID: session.id }) + + expect((yield* summary.diff({ sessionID: session.id })).map((item) => path.basename(item.file ?? ""))).toEqual([ + "main.cpp", + ]) + }), + { config: providerCfg }, + ), +) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0511c5183e4..cc8b8d17c1b1 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -144,6 +144,7 @@ export const { const fullSyncedSessions = new Set() const syncingSessions = new Map>() const hydratingSessions = new Map; parts: Set }>() + const diffRequests = new Map() const touchMessage = (sessionID: string, messageID: string) => { hydratingSessions.get(sessionID)?.messages.add(messageID) } @@ -167,6 +168,21 @@ export const { .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) } + function loadSessionDiff(sessionID: string) { + const request = (diffRequests.get(sessionID) ?? 0) + 1 + diffRequests.set(sessionID, request) + return sdk.client.session.diff({ sessionID }).then((result) => ({ request, data: result.data ?? [] })) + } + + function refreshSessionDiff(sessionID: string) { + void loadSessionDiff(sessionID) + .then((result) => { + if (diffRequests.get(sessionID) !== result.request) return + setStore("session_diff", sessionID, result.data) + }) + .catch(() => undefined) + } + event.subscribe((event, { directory, workspace }) => { switch (event.type) { case "server.instance.disposed": @@ -261,6 +277,7 @@ export const { break case "session.diff": + diffRequests.set(event.properties.sessionID, (diffRequests.get(event.properties.sessionID) ?? 0) + 1) setStore("session_diff", event.properties.sessionID, event.properties.diff) break @@ -313,6 +330,14 @@ export const { } case "message.updated": { + if ( + event.properties.info.role === "assistant" && + event.properties.info.time.completed && + (fullSyncedSessions.has(event.properties.info.sessionID) || + hydratingSessions.has(event.properties.info.sessionID)) + ) { + refreshSessionDiff(event.properties.info.sessionID) + } touchMessage(event.properties.info.sessionID, event.properties.info.id) const messages = store.message[event.properties.info.sessionID] if (!messages) { @@ -596,7 +621,7 @@ export const { sdk.client.session.get({ sessionID }, { throwOnError: true }), sdk.client.session.messages({ sessionID, limit: 100 }), sdk.client.session.todo({ sessionID }), - sdk.client.session.diff({ sessionID }), + loadSessionDiff(sessionID), ]) setStore( produce((draft) => { @@ -647,7 +672,7 @@ export const { } for (const message of removed) delete draft.part[message.id] draft.message[sessionID] = visible - draft.session_diff[sessionID] = diff.data ?? [] + if (diffRequests.get(sessionID) === diff.request) draft.session_diff[sessionID] = diff.data }), ) fullSyncedSessions.add(sessionID) diff --git a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx index 398c5c9ab524..753da9a8c251 100644 --- a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx @@ -85,6 +85,36 @@ test("stale session hydration does not overwrite live message parts", async () = } }) +test("completed assistant messages refresh the hydrated session diff", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + + let diffs = 0 + const changed = [{ file: "src/index.ts", additions: 1, deletions: 0 }] + const { app, emit, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(session) + if (url.pathname === `/session/${sessionID}/message` || url.pathname === `/session/${sessionID}/todo`) { + return json([]) + } + if (url.pathname === `/session/${sessionID}/diff`) { + diffs++ + return json(diffs === 1 ? [] : changed) + } + return undefined + }, tmp.path) + + try { + await sync.session.sync(sessionID) + emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } })) + await wait(() => sync.data.session_diff[sessionID]?.length === 1) + + expect(diffs).toBe(2) + expect(sync.data.session_diff[sessionID]).toEqual(changed) + } finally { + app.renderer.destroy() + } +}) + test("orphan live deltas do not suppress hydrated parts", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}")