From d373c562f2fd34ea38924a18161faa88e46592ab Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:44:24 -0400 Subject: [PATCH] fix(session): accept legacy summary diffs (#26579) Co-authored-by: Developer --- .../src/pages/session/session-side-panel.tsx | 11 +++++-- packages/opencode/src/cli/cmd/export.ts | 4 +-- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 4 ++- packages/opencode/src/session/summary.ts | 1 + packages/opencode/src/snapshot/index.ts | 6 ++-- .../test/server/httpapi-session.test.ts | 30 +++++++++++++++++++ .../test/session/schema-decoding.test.ts | 20 +++++++++++++ .../test/session/snapshot-tool-race.test.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/ui/src/components/session-diff.ts | 3 +- packages/ui/src/components/session-review.tsx | 9 ++++-- packages/ui/src/components/session-turn.tsx | 11 +++++-- 12 files changed, 88 insertions(+), 15 deletions(-) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 99197f0a70..66f5269bf9 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff + +function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { + return typeof value.file === "string" +} + export function SessionSidePanel(props: { canReview: () => boolean diffs: () => (SnapshotFileDiff | VcsFileDiff)[] @@ -70,7 +76,8 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const diffFiles = createMemo(() => props.diffs().map((d) => d.file)) + const diffs = createMemo(() => props.diffs().filter(renderDiff)) + const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b @@ -81,7 +88,7 @@ export function SessionSidePanel(props: { const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const out = new Map() - for (const diff of props.diffs()) { + for (const diff of diffs()) { const file = normalize(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index d1f9cd8d7d..9eb1faffea 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -23,10 +23,10 @@ function span(id: string, value: { value: string; start: number; end: number }) } } -function diff(kind: string, diffs: { file: string; patch?: string }[] | undefined) { +function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) { return diffs?.map((item, i) => ({ ...item, - file: redact(`${kind}-file`, String(i), item.file), + file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file), patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 6015150ec2..54059f4a2d 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -148,7 +148,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.session.length }, diff(sessionID) { - return sync.data.session_diff[sessionID] ?? [] + return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => + item.file === undefined ? [] : [{ ...item, file: item.file }], + ) }, todo(sessionID) { return sync.data.todo[sessionID] ?? [] diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index d5e52b91e9..e39bd85e9a 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -134,6 +134,7 @@ export const layer = Layer.effect( .read(["session_diff", input.sessionID]) .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[]))) const next = diffs.map((item) => { + if (item.file === undefined) return item const file = unquoteGitPath(item.file) if (file === item.file) return item return { ...item, file } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 8c8fd9156a..f54794a8a9 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -20,10 +20,10 @@ export const Patch = Schema.Struct({ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ - file: Schema.String, // Optional because legacy/imported `summary_diffs` on disk may omit - // the patch text (see #26574). Required-Schema rejected the whole - // /session//diff response and broke session loading on Desktop. + // file details and patch text. Required Schema rejected the whole + // session response and broke session loading on Desktop. + file: Schema.optional(Schema.String), patch: Schema.optional(Schema.String), additions: NonNegativeInt, deletions: NonNegativeInt, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index d646b35fcb..24c845183d 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -297,6 +297,36 @@ describe("session HttpApi", () => { ), ) + it.live( + "serves sessions with migrated summary diffs missing file details", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const session = yield* createSession(tmp.path, { title: "legacy diff" }) + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ + summary_additions: 1, + summary_deletions: 0, + summary_files: 1, + summary_diffs: [{ additions: 1, deletions: 0 }], + }) + .where(eq(SessionTable.id, session.id)) + .run(), + ), + ) + + const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect((yield* json(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }]) + }), + ), + ) + it.live( "serves lifecycle mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index bee2184e5b..e9628ce49f 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -83,6 +83,26 @@ describe("Session.Info", () => { expect(Session.Info.zod.parse(input)).toEqual(input) }) + test("accepts migrated summary diffs without file details", () => { + const input = { + id: sessionID, + slug: "legacy-diff", + projectID, + directory: "/tmp/proj", + title: "Legacy diff", + version: "0.1.0", + summary: { + additions: 1, + deletions: 0, + files: 1, + diffs: [{ additions: 1, deletions: 0 }], + }, + time: { created: 1, updated: 2 }, + } + expect(decode(input)).toEqual(input) + expect(Session.Info.zod.parse(input)).toEqual(input) + }) + test("rejects unbranded session id", () => { const bad = { id: "not-a-session-id" } as unknown expect(() => decode(bad)).toThrow() diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 82b88a72fd..671f62145c 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -238,7 +238,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget - let diff: Array<{ file: string }> = [] + let diff: Array<{ file?: string }> = [] for (let i = 0; i < 50; i++) { diff = yield* summary.diff({ sessionID: session.id }) if (diff.length > 0) break diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5a79ae2661..0d8ab61179 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -119,7 +119,7 @@ export type PermissionRequest = { } export type SnapshotFileDiff = { - file: string + file?: string patch?: string additions: number deletions: number diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index bd6bed88d8..60dcffd83d 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -12,7 +12,8 @@ type LegacyDiff = { status?: "added" | "deleted" | "modified" } -type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff +type SnapshotDiff = SnapshotFileDiff & { file: string } +type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff export type ViewDiff = { file: string diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 949402f439..1089587ee1 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -62,7 +62,12 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } +type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } function diff(value: unknown): value is ReviewDiff { @@ -108,7 +113,7 @@ export interface SessionReviewProps { classList?: Record classes?: { root?: string; header?: string; container?: string } actions?: JSX.Element - diffs: ReviewDiff[] + diffs: RawReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise lineCommentMention?: LineCommentEditorProps["mention"] diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index b35f718ef0..a39b9a7f64 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -90,6 +90,12 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } +type SummaryDiff = SnapshotFileDiff & { file: string } + +function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { + return typeof value.file === "string" +} + const hidden = new Set(["todowrite"]) function partState(part: PartType, showReasoningSummaries: boolean) { @@ -169,7 +175,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: SnapshotFileDiff[] = [] + const emptyDiffs: SummaryDiff[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) @@ -238,7 +244,8 @@ export function SessionTurn( const seen = new Set() return files - .reduceRight((result, diff) => { + .reduceRight((result, diff) => { + if (!summaryDiff(diff)) return result if (seen.has(diff.file)) return result seen.add(diff.file) result.push(diff)