From f5baef16f0fb1219f5fb1fa69a5985e07e138273 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 5 Sep 2026 17:08:30 -0400 Subject: [PATCH 1/4] fix(session): short-circuit diff for snapshot sessions with no tool edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session has step-start/step-finish snapshots but zero completed tool parts with filediff metadata (e.g. a plan-only session), return [] immediately instead of falling through to Fallback 1. Fallback 1 reads message.summary.diffs, which are populated by computeDiff — an unfiltered snapshot diff that captures ALL worktree changes between the two tree hashes, including edits from other concurrent sessions. This was the primary cross-session contamination vector for the Files Changed tab: a plan-mode session would show another session's file edits because the snapshot system uses a single shadow git repo per project. The early return is safe: if snapshots exist (from is defined), the session is 'modern' — Fallback 1 was designed for legacy sessions that predate the snapshot infrastructure, not as a supplement to the primary path. Closes harmoniqs/amicode#733 follow-up (computeDiff filtering). --- packages/opencode/src/session/session.ts | 6 ++ .../test/server/session-diff-scoped.test.ts | 84 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 8945693d5..a74a51466 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -946,6 +946,12 @@ const layer: Layer.Layer< } } + // Snapshots exist but the agent touched no files (e.g. plan-only session). + // Return empty — do NOT fall through to the legacy fallbacks, whose + // summary.diffs are unfiltered snapshot diffs that include cross-session + // changes (the computeDiff contamination vector from #733). + if (from) return [] as Snapshot.FileDiff[] + // Fallback 1: aggregate stored per-message summary diffs across the session. const seen = new Map() for (const msg of all) { diff --git a/packages/opencode/test/server/session-diff-scoped.test.ts b/packages/opencode/test/server/session-diff-scoped.test.ts index 0331ee842..6cede5a6e 100644 --- a/packages/opencode/test/server/session-diff-scoped.test.ts +++ b/packages/opencode/test/server/session-diff-scoped.test.ts @@ -456,4 +456,88 @@ describe("Session.diff — session-scoped agent diffs (#174)", () => { }), { git: true, config: { formatter: false, lsp: false } }, ) + + it.instance( + "returns [] for session with snapshots but no tool edits (plan-mode cross-session leak fix)", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "plan-mode-leak" }) + const snapshotSvc = yield* Snapshot.Service + const fs = yield* FSUtil.Service + + // Take the session-start snapshot (before any external edits) + const startHash = yield* snapshotSvc.track() + expect(startHash).toBeTruthy() + + // Create a user message + const userMsgID = MessageID.ascending() + yield* Session.use.updateMessage({ + id: userMsgID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "plan", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, + } satisfies SessionV1.User) + + // Attach step-start part (records session-start snapshot) + yield* Session.use.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: userMsgID, + type: "step-start", + snapshot: startHash!, + }) + + // Simulate an external change (another session editing a file) + yield* fs.writeWithDirs(path.join(test.directory, "foreign-edit.ts"), "edited by another session") + + // Take the step-finish snapshot (captures the foreign edit) + const endHash = yield* snapshotSvc.track() + expect(endHash).toBeTruthy() + expect(endHash).not.toBe(startHash) + + // Attach step-finish part + yield* Session.use.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: userMsgID, + type: "step-finish", + snapshot: endHash!, + reason: "done", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as any) + + // Store contaminated summary.diffs (simulating what computeDiff produces) + // This is what summarize() does after step-finish — unfiltered snapshot diff + yield* Session.use.updateMessage({ + id: userMsgID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "plan", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, + summary: { + diffs: [ + { file: "foreign-edit.ts", additions: 1, deletions: 0, status: "added" as const }, + ], + }, + } satisfies SessionV1.User) + + // NO tool parts with filediff — this is a plan-mode session + + // The diff should be empty: snapshots exist, so Fallback 1 should NOT + // serve contaminated summary.diffs from computeDiff + const response = yield* requestInDirectory( + pathFor(SessionPaths.diff, { sessionID: session.id }), + test.directory, + ) + expect(response.status).toBe(200) + const diffs = (yield* response.json) as Array<{ file: string }> + expect(diffs).toEqual([]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) }) From f3fb80ed9256bde4e5517d78c05f0c1e1ba13ea3 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 5 Sep 2026 17:13:35 -0400 Subject: [PATCH 2/4] fix(summary): filter computeDiff by agent-touched files (#733 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth: computeDiff now extracts agent-touched files from completed edit/write/patch/apply_patch tool parts and filters the snapshot diff to only those files, preventing cross-session changes from being stored in message.summary.diffs in the first place. Three cases: - Agent files exist (filediff metadata) → filter to those files only - Tools ran but none have filediff (e.g. bash) → return full diff (can't determine which files the agent touched) - No tools at all (plan-only) → return empty (all changes are external) This is the follow-up explicitly called out in the #733 commit message: 'a follow-up in the fork will add agent-file filtering to computeDiff to fix the leak at the source'. --- packages/opencode/src/session/summary.ts | 39 +++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 6484730d0..246020e48 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -82,6 +82,9 @@ const layer = Layer.effect( const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) { let from: string | undefined let to: string | undefined + const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch"]) + const agentFiles = new Set() + let hasAnyTool = false for (const item of input.messages) { if (!from) { for (const part of item.parts) { @@ -93,9 +96,43 @@ const layer = Layer.effect( } for (const part of item.parts) { if (part.type === "step-finish" && part.snapshot) to = part.snapshot + if (part.type === "tool") { + hasAnyTool = true + // Collect agent-touched files from completed edit tools (#733 follow-up). + // Without this filter, diffFull captures ALL worktree changes between + // the two tree hashes — including edits from concurrent sessions. + const toolPart = part as { tool?: string; state?: { status?: string; metadata?: Record } } + if (toolPart.tool && EDIT_TOOLS.has(toolPart.tool) && toolPart.state?.status === "completed") { + const filediff = toolPart.state?.metadata?.filediff as { file?: string } | undefined + if (filediff?.file) agentFiles.add(filediff.file) + } + } + } + } + if (from && to) { + const allDiffs = yield* snapshot.diffFull(from, to) + // If filediff-tracked agent files exist, filter the diff to only + // those files (prevents cross-session contamination). + if (agentFiles.size > 0) { + // Agent files are absolute paths (from tool filediff metadata); + // diff files are relative to the worktree. Match by suffix. + return allDiffs.filter((d: Snapshot.FileDiff) => { + if (!d.file) return false + const suffix = "/" + d.file + for (const af of agentFiles) { + if (af.endsWith(suffix) || af === d.file) return true + } + return false + }) } + // Tools ran but none had filediff metadata (e.g. bash creating + // files) — return the full diff since we can't determine which + // files the agent touched. + if (hasAnyTool) return allDiffs + // No tools at all (plan-only session) — the diff is entirely + // from external sources (other sessions). Return empty. + return [] as Snapshot.FileDiff[] } - if (from && to) return yield* snapshot.diffFull(from, to) return [] }) From e131c0512289896ec8f8f9735fd9a2e9ddc9e633 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 5 Sep 2026 17:17:25 -0400 Subject: [PATCH 3/4] fix(app): scope diff query placeholderData to the same session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace keepPreviousData with a session-scoped placeholder function that only preserves data from the same sessionID. This prevents the cross-session flash: when switching from Session A to Session B, the diff query no longer serves Session A's diffs as placeholder data in Session B's review panel. Intra-session refetches (diff_version bumps during a turn) still get smooth transitions — the placeholder is kept because the sessionID matches. Only inter-session switches (params.id change) drop the stale data, falling to the tool-metadata fallback which is correctly scoped to the new session's messages. Applied to both sessionDiffQuery and touchedFilesQuery. --- packages/app/src/pages/session.tsx | 16 +++++++++++++--- .../pages/session/v2/accumulate-diffs.test.ts | 6 ++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 3334c7532..dbfbabb4d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,7 +1,7 @@ import type { FilePart, Project, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createQuery, keepPreviousData, skipToken, useMutation } from "@tanstack/solid-query" +import { createQuery, skipToken, useMutation } from "@tanstack/solid-query" import { batch, ErrorBoundary, @@ -695,7 +695,14 @@ export default function Page() { return { queryKey: sessionDiffKey(), enabled: !!sessionID, - placeholderData: keepPreviousData, + // Keep previous data only for intra-session refetches (e.g. diff_version + // bumps), NOT across session switches. Cross-session keepPreviousData was + // the secondary leak vector: the old session's diffs appeared as + // placeholder in the new session's review panel. + placeholderData: (prev: SnapshotFileDiff[] | undefined, prevQuery: { queryKey?: readonly unknown[] } | undefined) => { + if (prevQuery?.queryKey?.[1] === sessionID) return prev + return undefined + }, queryFn: sessionID ? () => sdk() @@ -765,7 +772,10 @@ export default function Page() { return { queryKey: ["session-touched-files", sessionID ?? "", sessionDiffVersion()] as const, enabled: !!sessionID, - placeholderData: keepPreviousData, + placeholderData: (prev: Array<{ file: string; status: string }> | undefined, prevQuery: { queryKey?: readonly unknown[] } | undefined) => { + if (prevQuery?.queryKey?.[1] === sessionID) return prev + return undefined + }, staleTime: 30_000, queryFn: sessionID ? async () => { diff --git a/packages/app/src/pages/session/v2/accumulate-diffs.test.ts b/packages/app/src/pages/session/v2/accumulate-diffs.test.ts index cec265861..e35acf325 100644 --- a/packages/app/src/pages/session/v2/accumulate-diffs.test.ts +++ b/packages/app/src/pages/session/v2/accumulate-diffs.test.ts @@ -108,7 +108,9 @@ describe("accumulateDiffs", () => { expect(result[0].additions).toBe(6) expect(result[0].deletions).toBe(5) // A hypothetical net diff would be lower — the client must never show - // this stale/inflated data during a refetch. The fix is keepPreviousData - // on the query so the fallback never fires while server data exists. + // this stale/inflated data during a refetch. The fix is scoped + // placeholderData on the query (keep previous data only for the SAME + // session) so the fallback never fires during intra-session refetches, + // while inter-session switches correctly drop the stale data. }) }) From baa2f449d7b904551062a10e694f7d0c50cc80e6 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 5 Sep 2026 17:25:05 -0400 Subject: [PATCH 4/4] chore(i18n): add missing session.exportTrace key to all locales The key was added to en.ts in 77d7f8d1ee but missing from all non-English locale files, failing the i18n parity test. --- packages/app/src/i18n/ar.ts | 1 + packages/app/src/i18n/br.ts | 1 + packages/app/src/i18n/bs.ts | 1 + packages/app/src/i18n/da.ts | 1 + packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/i18n/fr.ts | 1 + packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 1 + packages/app/src/i18n/no.ts | 1 + packages/app/src/i18n/pl.ts | 1 + packages/app/src/i18n/ru.ts | 1 + packages/app/src/i18n/th.ts | 1 + packages/app/src/i18n/tr.ts | 1 + packages/app/src/i18n/uk.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + 17 files changed, 17 insertions(+) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index f5b871d14..9de1b4365 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -643,6 +643,7 @@ export const dict = { "session.share.action.view": "عرض", "session.share.copy.copied": "تم النسخ", "session.share.copy.copyLink": "نسخ الرابط", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "لا توجد خوادم LSP", "lsp.label.connected": "{{count}} LSP", "prompt.loading": "جارٍ تحميل الموجه...", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 8e553fbcd..b18c9d90d 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -651,6 +651,7 @@ export const dict = { "session.share.action.view": "Ver", "session.share.copy.copied": "Copiado", "session.share.copy.copyLink": "Copiar link", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "Nenhum servidor LSP", "lsp.label.connected": "{{count}} LSP", "prompt.loading": "Carregando prompt...", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index b1da656f3..84cac1b09 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -708,6 +708,7 @@ export const dict = { "session.share.action.view": "Prikaži", "session.share.copy.copied": "Kopirano", "session.share.copy.copyLink": "Kopiraj link", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "Nema LSP servera", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 6c8eccd87..b7fbf543a 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -705,6 +705,7 @@ export const dict = { "session.share.action.view": "Vis", "session.share.copy.copied": "Kopieret", "session.share.copy.copyLink": "Kopier link", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "Ingen LSP-servere", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 0fc55e759..b455c97b4 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -660,6 +660,7 @@ export const dict = { "session.share.action.view": "Ansehen", "session.share.copy.copied": "Kopiert", "session.share.copy.copyLink": "Link kopieren", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "Keine LSP-Server", "lsp.label.connected": "{{count}} LSP", "prompt.loading": "Lade Prompt...", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 72f3152ad..0cf7ee55a 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -711,6 +711,7 @@ export const dict = { "session.share.action.view": "Ver", "session.share.copy.copied": "Copiado", "session.share.copy.copyLink": "Copiar enlace", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "Sin servidores LSP", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index ee47be369..97fc99a32 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -656,6 +656,7 @@ export const dict = { "session.share.action.view": "Voir", "session.share.copy.copied": "Copié", "session.share.copy.copyLink": "Copier le lien", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "Aucun serveur LSP", "lsp.label.connected": "{{count}} LSP", "prompt.loading": "Chargement du prompt...", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 5789239ec..a83bb488b 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -647,6 +647,7 @@ export const dict = { "session.share.action.view": "表示", "session.share.copy.copied": "コピーしました", "session.share.copy.copyLink": "リンクをコピー", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "LSPサーバーなし", "lsp.label.connected": "{{count}} LSP", "prompt.loading": "プロンプトを読み込み中...", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 3c8710575..136daba17 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -523,6 +523,7 @@ export const dict = { "session.share.action.view": "보기", "session.share.copy.copied": "복사됨", "session.share.copy.copyLink": "링크 복사", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "LSP 서버 없음", "lsp.label.connected": "{{count}} LSP", "prompt.loading": "프롬프트 로드 중...", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 21fa44389..74fb5d19c 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -589,6 +589,7 @@ export const dict = { "session.share.action.view": "Vis", "session.share.copy.copied": "Kopiert", "session.share.copy.copyLink": "Kopier lenke", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "Ingen LSP-servere", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 8bee1740b..9a0a15b02 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -651,6 +651,7 @@ export const dict = { "session.share.action.view": "Widok", "session.share.copy.copied": "Skopiowano", "session.share.copy.copyLink": "Kopiuj link", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "Brak serwerów LSP", "lsp.label.connected": "{{count}} LSP", "prompt.loading": "Ładowanie promptu...", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 19de4d5d6..83257d76a 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -708,6 +708,7 @@ export const dict = { "session.share.action.view": "Посмотреть", "session.share.copy.copied": "Скопировано", "session.share.copy.copyLink": "Копировать ссылку", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "Нет LSP серверов", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 07d1ba248..64c3bfba9 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -702,6 +702,7 @@ export const dict = { "session.share.action.view": "ดู", "session.share.copy.copied": "คัดลอกแล้ว", "session.share.copy.copyLink": "คัดลอกลิงก์", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "ไม่มีเซิร์ฟเวอร์ LSP", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 09ab55fb9..cf0359f9a 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -713,6 +713,7 @@ export const dict = { "session.share.action.view": "Görüntüle", "session.share.copy.copied": "Kopyalandı", "session.share.copy.copyLink": "Bağlantı kopyala", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "LSP sunucusu yok", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 43aa325a3..b41531c2c 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -756,6 +756,7 @@ export const dict = { "session.share.action.view": "Переглянути", "session.share.copy.copied": "Скопійовано", "session.share.copy.copyLink": "Копіювати посилання", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "Немає серверів LSP", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index adfcff20e..8bd203429 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -698,6 +698,7 @@ export const dict = { "session.share.action.view": "查看", "session.share.copy.copied": "已复制", "session.share.copy.copyLink": "复制链接", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "没有 LSP 服务器", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 83bc71249..4ff353bfa 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -696,6 +696,7 @@ export const dict = { "session.share.action.view": "檢視", "session.share.copy.copied": "已複製", "session.share.copy.copyLink": "複製連結", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "沒有 LSP 伺服器", "lsp.label.connected": "{{count}} LSP",