Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "جارٍ تحميل الموجه...",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/br.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/bs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "プロンプトを読み込み中...",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "프롬프트 로드 중...",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/no.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/th.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/uk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/zht.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 13 additions & 3 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/pages/session/v2/accumulate-diffs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
})
})
6 changes: 6 additions & 0 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Snapshot.FileDiff>()
for (const msg of all) {
Expand Down
39 changes: 38 additions & 1 deletion packages/opencode/src/session/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
let hasAnyTool = false
for (const item of input.messages) {
if (!from) {
for (const part of item.parts) {
Expand All @@ -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<string, unknown> } }
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 []
})

Expand Down
84 changes: 84 additions & 0 deletions packages/opencode/test/server/session-diff-scoped.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
)
})
Loading