From d98be39344b8a39d16b62ce927be71a2c6a61a53 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Wed, 8 Apr 2026 13:49:04 -0500
Subject: [PATCH 1/3] fix(app): patch tool diff rendering
---
.../src/components/apply-patch-file.test.ts | 43 ++++++++++
.../ui/src/components/apply-patch-file.ts | 78 +++++++++++++++++++
packages/ui/src/components/message-part.tsx | 29 +------
3 files changed, 125 insertions(+), 25 deletions(-)
create mode 100644 packages/ui/src/components/apply-patch-file.test.ts
create mode 100644 packages/ui/src/components/apply-patch-file.ts
diff --git a/packages/ui/src/components/apply-patch-file.test.ts b/packages/ui/src/components/apply-patch-file.test.ts
new file mode 100644
index 0000000000..6c58581564
--- /dev/null
+++ b/packages/ui/src/components/apply-patch-file.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+import { patchFiles } from "./apply-patch-file"
+import { text } from "./session-diff"
+
+describe("apply patch file", () => {
+ test("parses patch metadata from the server", () => {
+ const file = patchFiles([
+ {
+ filePath: "/tmp/a.ts",
+ relativePath: "a.ts",
+ type: "update",
+ patch:
+ "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
+ additions: 1,
+ deletions: 1,
+ },
+ ])[0]
+
+ expect(file).toBeDefined()
+ expect(file?.view.fileDiff.name).toBe("a.ts")
+ expect(text(file!.view, "deletions")).toBe("one\ntwo\n")
+ expect(text(file!.view, "additions")).toBe("one\nthree\n")
+ })
+
+ test("keeps legacy before and after payloads working", () => {
+ const file = patchFiles([
+ {
+ filePath: "/tmp/a.ts",
+ relativePath: "a.ts",
+ type: "update",
+ before: "one\n",
+ after: "two\n",
+ additions: 1,
+ deletions: 1,
+ },
+ ])[0]
+
+ expect(file).toBeDefined()
+ expect(file?.view.patch).toContain("@@ -1,1 +1,1 @@")
+ expect(text(file!.view, "deletions")).toBe("one\n")
+ expect(text(file!.view, "additions")).toBe("two\n")
+ })
+})
diff --git a/packages/ui/src/components/apply-patch-file.ts b/packages/ui/src/components/apply-patch-file.ts
new file mode 100644
index 0000000000..8e0c540826
--- /dev/null
+++ b/packages/ui/src/components/apply-patch-file.ts
@@ -0,0 +1,78 @@
+import { normalize, type ViewDiff } from "./session-diff"
+
+type Kind = "add" | "update" | "delete" | "move"
+
+type Raw = {
+ filePath?: string
+ relativePath?: string
+ type?: Kind
+ patch?: string
+ diff?: string
+ before?: string
+ after?: string
+ additions?: number
+ deletions?: number
+ movePath?: string
+}
+
+export type ApplyPatchFile = {
+ filePath: string
+ relativePath: string
+ type: Kind
+ additions: number
+ deletions: number
+ movePath?: string
+ view: ViewDiff
+}
+
+function kind(value: unknown) {
+ if (value === "add" || value === "update" || value === "delete" || value === "move") return value
+}
+
+function status(type: Kind): "added" | "deleted" | "modified" {
+ if (type === "add") return "added"
+ if (type === "delete") return "deleted"
+ return "modified"
+}
+
+export function patchFile(raw: unknown): ApplyPatchFile | undefined {
+ if (!raw || typeof raw !== "object") return
+
+ const value = raw as Raw
+ const type = kind(value.type)
+ const filePath = typeof value.filePath === "string" ? value.filePath : undefined
+ const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath
+ const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined
+ const before = typeof value.before === "string" ? value.before : undefined
+ const after = typeof value.after === "string" ? value.after : undefined
+
+ if (!type || !filePath || !relativePath) return
+ if (!patch && before === undefined && after === undefined) return
+
+ const additions = typeof value.additions === "number" ? value.additions : 0
+ const deletions = typeof value.deletions === "number" ? value.deletions : 0
+ const movePath = typeof value.movePath === "string" ? value.movePath : undefined
+
+ return {
+ filePath,
+ relativePath,
+ type,
+ additions,
+ deletions,
+ movePath,
+ view: normalize({
+ file: relativePath,
+ patch,
+ before,
+ after,
+ additions,
+ deletions,
+ status: status(type),
+ }),
+ }
+}
+
+export function patchFiles(raw: unknown) {
+ if (!Array.isArray(raw)) return []
+ return raw.map(patchFile).filter((file): file is ApplyPatchFile => !!file)
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 3627eca409..02bd80ac9c 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -54,6 +54,7 @@ import { Spinner } from "./spinner"
import { TextShimmer } from "./text-shimmer"
import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title"
+import { patchFiles } from "./apply-patch-file"
import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
@@ -2014,24 +2015,12 @@ ToolRegistry.register({
},
})
-interface ApplyPatchFile {
- filePath: string
- relativePath: string
- type: "add" | "update" | "delete" | "move"
- diff: string
- before: string
- after: string
- additions: number
- deletions: number
- movePath?: string
-}
-
ToolRegistry.register({
name: "apply_patch",
render(props) {
const i18n = useI18n()
const fileComponent = useFileComponent()
- const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
+ const files = createMemo(() => patchFiles(props.metadata.files))
const pending = createMemo(() => props.status === "pending" || props.status === "running")
const single = createMemo(() => {
const list = files()
@@ -2137,12 +2126,7 @@ ToolRegistry.register({
-
+
@@ -2212,12 +2196,7 @@ ToolRegistry.register({
}
>
-
+
From 689b1a4b3ab3c33aecc76b84c579b2efce444d6c Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Wed, 8 Apr 2026 14:02:23 -0500
Subject: [PATCH 2/3] fix(app): diff list normalization
---
.../src/context/global-sync/event-reducer.ts | 5 +-
packages/app/src/context/sync.tsx | 5 +-
packages/app/src/pages/session.tsx | 17 ++---
packages/app/src/utils/diffs.test.ts | 74 +++++++++++++++++++
packages/app/src/utils/diffs.ts | 49 ++++++++++++
packages/ui/src/components/session-review.tsx | 24 +++++-
6 files changed, 159 insertions(+), 15 deletions(-)
create mode 100644 packages/app/src/utils/diffs.test.ts
create mode 100644 packages/app/src/utils/diffs.ts
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index 01248e20e8..500013c1da 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -14,6 +14,7 @@ import type {
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
+import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -162,7 +163,7 @@ export function applyDirectoryEvent(input: {
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
- input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
+ input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break
}
case "todo.updated": {
@@ -177,7 +178,7 @@ export function applyDirectoryEvent(input: {
break
}
case "message.updated": {
- const info = (event.properties as { info: Message }).info
+ const info = clean((event.properties as { info: Message }).info)
const messages = input.store.message[info.sessionID]
if (!messages) {
input.setStore("message", info.sessionID, [info])
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index b023e8ddc1..fb02a2d2d0 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -13,6 +13,7 @@ import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
+import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -300,7 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
- const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
+ const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
@@ -509,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
- setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
+ setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index cf50fbe908..eb6a494119 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -58,6 +58,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id"
+import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
@@ -430,7 +431,7 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID)
- const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+ const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!sync.project)
@@ -611,7 +612,7 @@ export default function Page() {
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
- setVcs("diff", mode, result.data ?? [])
+ setVcs("diff", mode, list(result.data))
setVcs("ready", mode, true)
})
.catch((error) => {
@@ -649,7 +650,7 @@ export default function Page() {
return open
}, desktopReviewOpen())
- const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
+ const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
const changesOptions = createMemo(() => {
const list: ChangeMode[] = []
@@ -669,15 +670,11 @@ export default function Page() {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
- if (store.changes === "git") return vcs.diff.git
- if (store.changes === "branch") return vcs.diff.branch
+ if (store.changes === "git") return list(vcs.diff.git)
+ if (store.changes === "branch") return list(vcs.diff.branch)
return turnDiffs()
})
- const reviewCount = createMemo(() => {
- if (store.changes === "git") return vcs.diff.git.length
- if (store.changes === "branch") return vcs.diff.branch.length
- return turnDiffs().length
- })
+ const reviewCount = createMemo(() => reviewDiffs().length)
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts
new file mode 100644
index 0000000000..5fbca469b7
--- /dev/null
+++ b/packages/app/src/utils/diffs.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, test } from "bun:test"
+import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
+import type { Message } from "@opencode-ai/sdk/v2/client"
+import { diffs, message } from "./diffs"
+
+const item = {
+ file: "src/app.ts",
+ patch: "@@ -1 +1 @@\n-old\n+new\n",
+ additions: 1,
+ deletions: 1,
+ status: "modified",
+} satisfies SnapshotFileDiff
+
+describe("diffs", () => {
+ test("keeps valid arrays", () => {
+ expect(diffs([item])).toEqual([item])
+ })
+
+ test("wraps a single diff object", () => {
+ expect(diffs(item)).toEqual([item])
+ })
+
+ test("reads keyed diff objects", () => {
+ expect(diffs({ a: item })).toEqual([item])
+ })
+
+ test("drops invalid entries", () => {
+ expect(
+ diffs([
+ item,
+ { file: "src/bad.ts", additions: 1, deletions: 1 },
+ { patch: item.patch, additions: 1, deletions: 1 },
+ ]),
+ ).toEqual([item])
+ })
+})
+
+describe("message", () => {
+ test("normalizes user summaries with object diffs", () => {
+ const input = {
+ id: "msg_1",
+ sessionID: "ses_1",
+ role: "user",
+ time: { created: 1 },
+ agent: "build",
+ model: { providerID: "openai", modelID: "gpt-5" },
+ summary: {
+ title: "Edit",
+ diffs: { a: item },
+ },
+ } as unknown as Message
+
+ expect(message(input)).toMatchObject({
+ summary: {
+ title: "Edit",
+ diffs: [item],
+ },
+ })
+ })
+
+ test("drops invalid user summaries", () => {
+ const input = {
+ id: "msg_1",
+ sessionID: "ses_1",
+ role: "user",
+ time: { created: 1 },
+ agent: "build",
+ model: { providerID: "openai", modelID: "gpt-5" },
+ summary: true,
+ } as unknown as Message
+
+ expect(message(input)).toMatchObject({ summary: undefined })
+ })
+})
diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts
new file mode 100644
index 0000000000..0cb2504fbe
--- /dev/null
+++ b/packages/app/src/utils/diffs.ts
@@ -0,0 +1,49 @@
+import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { Message } from "@opencode-ai/sdk/v2/client"
+
+type Diff = SnapshotFileDiff | VcsFileDiff
+
+function diff(value: unknown): value is Diff {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false
+ if (!("file" in value) || typeof value.file !== "string") return false
+ if (!("patch" in value) || typeof value.patch !== "string") return false
+ if (!("additions" in value) || typeof value.additions !== "number") return false
+ if (!("deletions" in value) || typeof value.deletions !== "number") return false
+ if (!("status" in value) || value.status === undefined) return true
+ return value.status === "added" || value.status === "deleted" || value.status === "modified"
+}
+
+function object(value: unknown): value is Record {
+ return !!value && typeof value === "object" && !Array.isArray(value)
+}
+
+export function diffs(value: unknown): Diff[] {
+ if (Array.isArray(value) && value.every(diff)) return value
+ if (Array.isArray(value)) return value.filter(diff)
+ if (diff(value)) return [value]
+ if (!object(value)) return []
+ return Object.values(value).filter(diff)
+}
+
+export function message(value: Message): Message {
+ if (value.role !== "user") return value
+
+ const raw = value.summary as unknown
+ if (raw === undefined) return value
+ if (!object(raw)) return { ...value, summary: undefined }
+
+ const title = typeof raw.title === "string" ? raw.title : undefined
+ const body = typeof raw.body === "string" ? raw.body : undefined
+ const next = diffs(raw.diffs)
+
+ if (title === raw.title && body === raw.body && next === raw.diffs) return value
+
+ return {
+ ...value,
+ summary: {
+ ...(title === undefined ? {} : { title }),
+ ...(body === undefined ? {} : { body }),
+ diffs: next,
+ },
+ }
+}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 90da853efc..4a7205a5dc 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -65,6 +65,26 @@ export type SessionReviewFocus = { file: string; id: string }
type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult }
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult }
+function diff(value: unknown): value is ReviewDiff {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false
+ if (!("file" in value) || typeof value.file !== "string") return false
+ if (!("additions" in value) || typeof value.additions !== "number") return false
+ if (!("deletions" in value) || typeof value.deletions !== "number") return false
+ if ("patch" in value && value.patch !== undefined && typeof value.patch !== "string") return false
+ if ("before" in value && value.before !== undefined && typeof value.before !== "string") return false
+ if ("after" in value && value.after !== undefined && typeof value.after !== "string") return false
+ if (!("status" in value) || value.status === undefined) return true
+ return value.status === "added" || value.status === "deleted" || value.status === "modified"
+}
+
+function list(value: unknown): ReviewDiff[] {
+ if (Array.isArray(value) && value.every(diff)) return value
+ if (Array.isArray(value)) return value.filter(diff)
+ if (diff(value)) return [value]
+ if (!value || typeof value !== "object") return []
+ return Object.values(value).filter(diff)
+}
+
export interface SessionReviewProps {
title?: JSX.Element
empty?: JSX.Element
@@ -157,7 +177,9 @@ export const SessionReview = (props: SessionReviewProps) => {
const opened = () => store.opened
const open = () => props.open ?? store.open
- const items = createMemo- (() => props.diffs.map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })))
+ const items = createMemo
- (() =>
+ list(props.diffs).map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })),
+ )
const files = createMemo(() => items().map((diff) => diff.file))
const grouped = createMemo(() => {
const next = new Map()
From 00cb8839ae71a1aae0a9bac92a1f53c61ef70bf9 Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Wed, 8 Apr 2026 14:52:34 -0500
Subject: [PATCH 3/3] fix: dont show invalid variants for BP (#21555)
---
packages/opencode/src/provider/transform.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index f536e04bfa..1118320992 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -376,7 +376,8 @@ export namespace ProviderTransform {
id.includes("mistral") ||
id.includes("kimi") ||
id.includes("k2p5") ||
- id.includes("qwen")
+ id.includes("qwen") ||
+ id.includes("big-pickle")
)
return {}