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`, "{}")