fix(vscode): restore inline tool diffs (#9688)

* fix(vscode): restore inline tool diffs

* fix(vscode): render tool patches in kilo ui

* style: format long regex assignments and add change marker comment

Reformat multi-line regex match assignments in kilo-ui-contract test
to satisfy line length limits, and annotate the `contents(diff)` call
in session-diff with a kilocode_change tracking comment.

* fix(kilo-vscode): append trailing newlines to expected diff content assertions

Update test expectations in diff-session-source to include trailing
newlines in before/after content, matching actual file content behavior.

* fix(vscode): guard empty-patch diffs in session turn accordion

Match diff-session-source.ts:99 behavior by short-circuiting contents()
when the patch is empty (binary or summarized files), so the accordion
content stays empty instead of rendering a confusing whitespace-only
diff.

---------

Co-authored-by: Imanol Maiztegui <imanol.mzd@gmail.com>
This commit is contained in:
Marius
2026-05-07 11:01:47 +02:00
committed by GitHub
parent c1ea8100e1
commit 3095efcc4c
13 changed files with 409 additions and 99 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@opencode-ai/ui": patch
---
Restore inline diff previews after session reloads while keeping large file contents stripped from VS Code webview messages.
@@ -53,6 +53,7 @@ import { busy, createThrottledValue, useToolFade, useContextToolPending } from "
import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results"
import { ShellRollingResults } from "./shell-rolling-results"
import { extractFilePathFromHref } from "../file-path"
import { contents } from "./session-diff"
// Windows CLI tools (e.g. winget) use \r to overwrite progress bars in-place.
// Without this, every progress frame renders as a separate visual line.
@@ -2118,8 +2119,13 @@ ToolRegistry.register({
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => busy(props.status)
const reveal = useToolReveal(pending, () => props.reveal !== false)
const before = () => props.metadata?.filediff?.before ?? props.input.oldString ?? ""
const after = () => props.metadata?.filediff?.after ?? props.input.newString ?? ""
const view = createMemo(() => {
const diff = props.metadata?.filediff
if (!diff?.patch) return
return contents(diff)
})
const before = () => view()?.before ?? props.metadata?.filediff?.before ?? props.input.oldString ?? ""
const after = () => view()?.after ?? props.metadata?.filediff?.after ?? props.input.newString ?? ""
const canOpenDiff = () => !!data.openDiff && !!path() && (before() !== "" || after() !== "")
const canOpenFile = () => !!data.openFile && !!path()
@@ -2238,10 +2244,25 @@ ToolRegistry.register({
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => busy(props.status)
const reveal = useToolReveal(pending, () => props.reveal !== false)
const view = createMemo(() => {
const diff = props.metadata?.filediff
if (!diff?.patch) return
return contents(diff)
})
const handleFileClick = (e: MouseEvent) => {
if (!data.openFile || !props.input.filePath) return
e.stopPropagation()
if (data.openDiff && view()) {
data.openDiff({
file: props.metadata?.filediff?.file || props.input.filePath,
before: view()!.before,
after: view()!.after,
additions: props.metadata?.filediff?.additions ?? 0,
deletions: props.metadata?.filediff?.deletions ?? 0,
})
return
}
if (!data.openFile || !props.input.filePath) return
data.openFile(props.input.filePath)
}
@@ -2263,8 +2284,13 @@ ToolRegistry.register({
<ToolMetaLine
filename={name()}
path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined}
changes={props.metadata.filediff}
animate={reveal()}
onClick={data.openFile && props.input.filePath ? handleFileClick : undefined}
onClick={
(view() && data.openDiff) || (data.openFile && props.input.filePath)
? handleFileClick
: undefined
}
/>
)}
</Show>
@@ -2273,19 +2299,40 @@ ToolRegistry.register({
</div>
}
>
<Show when={props.input.content && path()}>
<ToolFileAccordion path={path()}>
<Show when={(props.input.content || view()) && path()}>
<ToolFileAccordion
path={path()}
actions={
<Show when={!pending() && props.metadata.filediff}>
{(diff) => <ToolChanges changes={diff()} animate={reveal()} />}
</Show>
}
>
<div data-component="write-content">
<Dynamic
component={fileComponent}
mode="text"
file={{
name: props.input.filePath,
contents: props.input.content,
cacheKey: checksum(props.input.content),
}}
overflow="scroll"
/>
<Show
when={view()}
fallback={
<Dynamic
component={fileComponent}
mode="text"
file={{
name: props.input.filePath,
contents: props.input.content,
cacheKey: checksum(props.input.content),
}}
overflow="scroll"
/>
}
>
{(diff) => (
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: props.metadata?.filediff?.file || props.input.filePath, contents: diff().before }}
after={{ name: props.metadata?.filediff?.file || props.input.filePath, contents: diff().after }}
/>
)}
</Show>
</div>
</ToolFileAccordion>
</Show>
@@ -2300,6 +2347,7 @@ interface ApplyPatchFile {
filePath: string
relativePath: string
type: "add" | "update" | "delete" | "move"
patch?: string
diff: string
before?: string
after?: string
@@ -2315,6 +2363,13 @@ ToolRegistry.register({
const i18n = useI18n()
const fileComponent = useFileComponent()
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
const view = (file: ApplyPatchFile) => {
if (file.patch)
return contents({ file: file.relativePath, patch: file.patch, additions: file.additions, deletions: file.deletions })
if (file.diff)
return contents({ file: file.relativePath, patch: file.diff, additions: file.additions, deletions: file.deletions })
if (file.before !== undefined || file.after !== undefined) return { before: file.before ?? "", after: file.after ?? "" }
}
const pending = createMemo(() => busy(props.status))
const reveal = useToolReveal(pending, () => props.reveal !== false)
const single = createMemo(() => {
@@ -2452,15 +2507,17 @@ ToolRegistry.register({
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible() && file.before !== undefined}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: file.filePath, contents: file.before }}
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
/>
</div>
<Show when={visible() && view(file)}>
{(diff) => (
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: file.filePath, contents: diff().before }}
after={{ name: file.movePath ?? file.filePath, contents: diff().after }}
/>
</div>
)}
</Show>
</Accordion.Content>
</Accordion.Item>
@@ -2500,15 +2557,17 @@ ToolRegistry.register({
</Switch>
}
>
<Show when={file().before !== undefined}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: file().filePath, contents: file().before }}
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
/>
</div>
<Show when={view(file())}>
{(diff) => (
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: file().filePath, contents: diff().before }}
after={{ name: file().movePath ?? file().filePath, contents: diff().after }}
/>
</div>
)}
</Show>
</ToolFileAccordion>
)}
@@ -1 +1 @@
export { normalize, text, type ViewDiff } from "../../../ui/src/components/session-diff"
export { contents, normalize, text, type DiffText, type ViewDiff } from "../../../ui/src/components/session-diff"
@@ -16,6 +16,7 @@
// Max chars to keep for truncated output fields (bash metadata.output etc.)
const OUTPUT_CAP = 4000
const PATCH_CAP = 64_000
// ---------------------------------------------------------------------------
// Helpers
@@ -32,11 +33,22 @@ function cap(v: unknown, limit = OUTPUT_CAP): string | undefined {
return v.slice(0, limit) + `\n… (truncated, ${v.length - limit} chars omitted)`
}
function patch(v: unknown): string | undefined {
if (typeof v !== "string") return undefined
if (v.length > PATCH_CAP) return undefined
return v
}
function withPatch(v: unknown): { patch: string } | {} {
const kept = patch(v)
return kept ? { patch: kept } : {}
}
// ---------------------------------------------------------------------------
// Per-tool slimmers
// ---------------------------------------------------------------------------
/** edit: strip filediff.before/after (webview falls back to input.oldString/newString). */
/** edit: strip filediff.before/after while preserving bounded patches for inline diffs. */
function slimEdit(state: Record<string, unknown>): Record<string, unknown> {
const next = { ...state }
const meta = state.metadata
@@ -50,6 +62,7 @@ function slimEdit(state: Record<string, unknown>): Record<string, unknown> {
if (isObj(fd)) {
result.filediff = {
...(typeof fd.file === "string" ? { file: fd.file } : {}),
...withPatch(fd.patch),
additions: typeof fd.additions === "number" ? fd.additions : 0,
deletions: typeof fd.deletions === "number" ? fd.deletions : 0,
}
@@ -59,7 +72,7 @@ function slimEdit(state: Record<string, unknown>): Record<string, unknown> {
return next
}
/** apply_patch: strip files[].before/after/diff, metadata.diff, + input.patchText. */
/** apply_patch: strip full file contents and input patch text while preserving bounded rendered patches. */
function slimPatch(state: Record<string, unknown>): Record<string, unknown> {
const next = { ...state }
const meta = state.metadata
@@ -67,14 +80,18 @@ function slimPatch(state: Record<string, unknown>): Record<string, unknown> {
const slim: Record<string, unknown> = {}
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
if (Array.isArray(meta.files)) {
slim.files = (meta.files as Record<string, unknown>[]).map((f) => ({
filePath: f.filePath,
relativePath: f.relativePath,
type: f.type,
additions: f.additions,
deletions: f.deletions,
movePath: f.movePath,
}))
slim.files = (meta.files as Record<string, unknown>[]).map((f) => {
const diff = patch(f.patch) ?? patch(f.diff)
return {
filePath: f.filePath,
relativePath: f.relativePath,
type: f.type,
...withPatch(diff),
additions: f.additions,
deletions: f.deletions,
movePath: f.movePath,
}
})
}
next.metadata = slim
}
@@ -101,6 +118,7 @@ function slimMultiedit(state: Record<string, unknown>): Record<string, unknown>
if (isObj(fd)) {
rs.filediff = {
...(typeof fd.file === "string" ? { file: fd.file } : {}),
...withPatch(fd.patch),
additions: typeof fd.additions === "number" ? fd.additions : 0,
deletions: typeof fd.deletions === "number" ? fd.deletions : 0,
}
@@ -130,6 +148,7 @@ function slimWrite(state: Record<string, unknown>): Record<string, unknown> {
if (isObj(fd)) {
slim.filediff = {
...(typeof fd.file === "string" ? { file: fd.file } : {}),
...withPatch(fd.patch),
additions: typeof fd.additions === "number" ? fd.additions : 0,
deletions: typeof fd.deletions === "number" ? fd.deletions : 0,
}
@@ -75,8 +75,8 @@ describe("SessionDiffSource.initialFetch", () => {
const foo = diffsMsg.diffs[0]!
expect(foo.file).toBe("foo.ts")
expect(foo.before).toBe("keep\nold")
expect(foo.after).toBe("keep\nnew")
expect(foo.before).toBe("keep\nold\n")
expect(foo.after).toBe("keep\nnew\n")
expect(foo.additions).toBe(1)
expect(foo.deletions).toBe(1)
expect(foo.status).toBe("modified")
@@ -137,8 +137,29 @@ describe("Edit tool diff-first click contract (source)", () => {
const editBlock = editBlockMatch?.[0] ?? ""
it("edit tool derives before/after content from filediff or input", () => {
expect(editBlock).toMatch(/filediff\?\.before\s*\?\?.*oldString/)
expect(editBlock).toMatch(/filediff\?\.after\s*\?\?.*newString/)
expect(editBlock).toContain("contents(diff)")
expect(editBlock).toMatch(/view\(\)\?\.before\s*\?\?.*filediff\?\.before\s*\?\?.*oldString/)
expect(editBlock).toMatch(/view\(\)\?\.after\s*\?\?.*filediff\?\.after\s*\?\?.*newString/)
})
})
describe("Write and apply_patch patch rendering contracts (source)", () => {
const src = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8")
const writeBlock =
src.match(/ToolRegistry\.register\(\{\s*name:\s*"write"[\s\S]*?(?=ToolRegistry\.register\(|$)/)?.[0] ?? ""
const patchBlock =
src.match(/ToolRegistry\.register\(\{\s*name:\s*"apply_patch"[\s\S]*?(?=ToolRegistry\.register\(|$)/)?.[0] ?? ""
it("write tool can render from filediff.patch when input.content is stripped", () => {
expect(writeBlock).toContain("contents(diff)")
expect(writeBlock).toContain("props.input.content || view()")
expect(writeBlock).toContain('mode="diff"')
})
it("apply_patch tool can render from patch metadata without before/after", () => {
expect(patchBlock).toContain("file.patch")
expect(patchBlock).toContain("contents({ file: file.relativePath, patch: file.patch")
expect(patchBlock).toContain('mode="diff"')
})
})
@@ -13,12 +13,18 @@ function bytes(obj: unknown): number {
return JSON.stringify(obj).length
}
function bigPatch() {
return `Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,1 +1,1 @@\n-${"x".repeat(70_000)}\n+${"y".repeat(70_000)}\n`
}
/**
* Hard ceiling per slimmed tool state (JSON bytes). Real slimmed parts
* should be well under this. If a slimmer leaks even one file-content
* field (~50-500 KB each) the test blows past this immediately.
*/
const MAX_SLIM_BYTES = 10_000
const 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"
const BIG = "x".repeat(200_000) // 200 KB — typical file content size
const DIAG = [
@@ -50,7 +56,7 @@ describe("slimPart", () => {
output: "Edit applied successfully.",
metadata: {
diff: BIG,
filediff: { file: "/a.ts", before: BIG, after: BIG, additions: 3, deletions: 1 },
filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 3, deletions: 1 },
diagnostics: { "/a.ts": DIAG },
},
})
@@ -63,6 +69,7 @@ describe("slimPart", () => {
const slim = slimPart(heavy) as Record<string, any>
const meta = slim.state.metadata
expect(meta.filediff.file).toBe("/a.ts")
expect(meta.filediff.patch).toBe(PATCH)
expect(meta.filediff.additions).toBe(3)
expect(meta.filediff.deletions).toBe(1)
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
@@ -81,6 +88,19 @@ describe("slimPart", () => {
})
expect(bytes(slimPart(withUnknown))).toBeLessThan(MAX_SLIM_BYTES)
})
it("drops oversized filediff patches", () => {
const wide = part("edit", {
...heavy.state,
metadata: {
...(heavy.state.metadata as object),
filediff: { file: "/a.ts", patch: bigPatch(), before: BIG, after: BIG, additions: 1, deletions: 1 },
},
})
const slim = slimPart(wide) as Record<string, any>
expect(slim.state.metadata.filediff.patch).toBeUndefined()
expect(bytes(slim)).toBeLessThan(MAX_SLIM_BYTES)
})
})
// -----------------------------------------------------------------------
@@ -100,6 +120,7 @@ describe("slimPart", () => {
type: "update",
before: BIG,
after: BIG,
patch: PATCH,
diff: BIG,
additions: 5,
deletions: 2,
@@ -130,6 +151,7 @@ describe("slimPart", () => {
expect(meta.files[0].filePath).toBe("/a.ts")
expect(meta.files[0].relativePath).toBe("a.ts")
expect(meta.files[0].type).toBe("update")
expect(meta.files[0].patch).toBe(PATCH)
expect(meta.files[0].additions).toBe(5)
expect(meta.files[1].type).toBe("add")
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
@@ -163,6 +185,28 @@ describe("slimPart", () => {
})
expect(bytes(slimPart(withUnknown))).toBeLessThan(MAX_SLIM_BYTES)
})
it("drops oversized per-file patches", () => {
const wide = part("apply_patch", {
...heavy.state,
metadata: {
...(heavy.state.metadata as Record<string, unknown>),
files: [
{
filePath: "/a.ts",
relativePath: "a.ts",
type: "update",
patch: bigPatch(),
additions: 1,
deletions: 1,
},
],
},
})
const slim = slimPart(wide) as Record<string, any>
expect(slim.state.metadata.files[0].patch).toBeUndefined()
expect(bytes(slim)).toBeLessThan(MAX_SLIM_BYTES)
})
})
// -----------------------------------------------------------------------
@@ -178,7 +222,7 @@ describe("slimPart", () => {
diagnostics: { "/a.ts": DIAG },
results: [
{
filediff: { file: "/a.ts", before: BIG, after: BIG, additions: 1, deletions: 1 },
filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 1, deletions: 1 },
diagnostics: { "/a.ts": DIAG },
diff: BIG,
},
@@ -195,6 +239,7 @@ describe("slimPart", () => {
const slim = slimPart(heavy) as Record<string, any>
const meta = slim.state.metadata
expect(meta.results[0].filediff.file).toBe("/a.ts")
expect(meta.results[0].filediff.patch).toBe(PATCH)
expect(meta.results[0].filediff.additions).toBe(1)
expect(meta.results[0].diagnostics).toEqual({ "/a.ts": DIAG })
expect(meta.results[1].filediff.file).toBe("/b.ts")
@@ -233,7 +278,7 @@ describe("slimPart", () => {
filepath: "/a.ts",
exists: true,
diff: BIG,
filediff: { file: "/a.ts", before: BIG, after: BIG, additions: 100, deletions: 0 },
filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 100, deletions: 0 },
diagnostics: { "/a.ts": DIAG },
},
})
@@ -248,6 +293,7 @@ describe("slimPart", () => {
expect(meta.filepath).toBe("/a.ts")
expect(meta.exists).toBe(true)
expect(meta.filediff.file).toBe("/a.ts")
expect(meta.filediff.patch).toBe(PATCH)
expect(meta.filediff.additions).toBe(100)
expect(meta.filediff.deletions).toBe(0)
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
@@ -12,7 +12,7 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { normalize, text } from "@kilocode/kilo-ui/session-diff"
import { contents } from "@kilocode/kilo-ui/session-diff"
import { LanguageProvider, useLanguage } from "../src/context/language"
import { ServerProvider, useServer } from "../src/context/server"
import { getVSCodeAPI, VSCodeProvider } from "../src/context/vscode"
@@ -68,8 +68,8 @@ const DiffVirtualContent: Component = () => {
if (!d) return { before: "", after: "" }
if (d.before !== undefined || d.after !== undefined) return { before: d.before ?? "", after: d.after ?? "" }
if (d.patch) {
const view = normalize(d as { file: string; patch: string; additions: number; deletions: number })
return { before: text(view, "deletions"), after: text(view, "additions") }
const view = contents(d as { file: string; patch: string; additions: number; deletions: number })
return { before: view.before, after: view.after }
}
return { before: "", after: "" }
})
@@ -3,7 +3,7 @@ import { Diff } from "@kilocode/kilo-ui/diff"
import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { parsePatch } from "diff"
import { contents } from "@kilocode/kilo-ui/session-diff"
import type { PermissionFileDiff } from "../../types/messages"
import { useVSCode } from "../../context/vscode"
@@ -11,29 +11,6 @@ interface PermissionDiffProps {
filediff: PermissionFileDiff
}
function patchText(patch: string) {
const parsed = parsePatch(patch)[0]
if (!parsed) return { before: "", after: "" }
const before: string[] = []
const after: string[] = []
for (const hunk of parsed.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("-")) {
before.push(line.slice(1))
continue
}
if (line.startsWith("+")) {
after.push(line.slice(1))
continue
}
before.push(line.slice(1))
after.push(line.slice(1))
}
}
return { before: before.join("\n"), after: after.join("\n") }
}
export const PermissionDiff: Component<PermissionDiffProps> = (props) => {
const vscode = useVSCode()
const filename = createMemo(() => {
@@ -50,7 +27,10 @@ export const PermissionDiff: Component<PermissionDiffProps> = (props) => {
const resolved = createMemo(() => {
const fd = props.filediff
if (fd.before !== undefined || fd.after !== undefined) return { before: fd.before ?? "", after: fd.after ?? "" }
if (fd.patch) return patchText(fd.patch)
if (fd.patch) {
const view = contents(fd)
return { before: view.before, after: view.after }
}
return { before: "", after: "" }
})
@@ -19,7 +19,7 @@ import { Icon } from "@kilocode/kilo-ui/icon"
import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header"
import { useData } from "@kilocode/kilo-ui/context/data"
import { useFileComponent } from "@kilocode/kilo-ui/context/file"
import { normalize } from "@kilocode/kilo-ui/session-diff"
import { contents } from "@kilocode/kilo-ui/session-diff"
import { useI18n } from "@kilocode/kilo-ui/context/i18n"
import { AssistantMessage } from "./AssistantMessage"
import type {
@@ -280,11 +280,17 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
<Accordion.Content>
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={fileComponent}
mode="diff"
fileDiff={normalize(diff).fileDiff}
/>
{(() => {
const view = diff.patch === "" ? { before: "", after: "" } : contents(diff)
return (
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: view.before }}
after={{ name: diff.file, contents: view.after }}
/>
)
})()}
</div>
</Show>
</Accordion.Content>
+40 -8
View File
@@ -692,6 +692,17 @@ export const cursor = {
// kilocode_change start - strip bloated metadata fields from stored parts to prevent multi-MB payloads
// This handles both legacy data that was stored with full file contents and keeps the API response lean.
function stripPatch(value: unknown) {
if (typeof value !== "string") return undefined
if (Buffer.byteLength(value) > Snapshot.MAX_DIFF_SIZE) return undefined
return value
}
function withPatch(value: unknown) {
const kept = stripPatch(value)
return kept ? { patch: kept } : {}
}
export function stripPartMetadata(part: Part): Part {
// kilocode_change - exported for testing
if (part.type !== "tool") return part
@@ -703,20 +714,41 @@ export function stripPartMetadata(part: Part): Part {
let changed = false
let next = meta
// Strip edit tool's filediff.before/after (full file contents)
if (meta.filediff && (meta.filediff.before || meta.filediff.after)) {
const { before, after, ...rest } = meta.filediff
next = { ...next, filediff: rest }
if (meta.diff !== undefined) {
const { diff, ...rest } = next
next = rest
changed = true
}
// Strip apply_patch tool's files[].before/after (full file contents per file)
if (Array.isArray(meta.files) && meta.files.length > 0 && meta.files[0]?.before !== undefined) {
// Strip edit/write tool filediff.before/after (full file contents) and cap patches.
if (meta.filediff) {
const { before, after, patch, ...rest } = meta.filediff
next = { ...next, filediff: { ...rest, ...withPatch(patch) } }
changed = true
}
// Strip apply_patch tool's files[].before/after (full file contents per file) and cap per-file patches.
if (Array.isArray(meta.files) && meta.files.length > 0) {
next = {
...next,
files: meta.files.map((f: Record<string, unknown>) => {
const { before, after, ...rest } = f
return rest
const { before, after, patch, diff, ...rest } = f
const kept = stripPatch(patch) ?? stripPatch(diff)
return { ...rest, ...(kept ? { patch: kept } : {}) }
}),
}
changed = true
}
if (Array.isArray(meta.results) && meta.results.length > 0) {
next = {
...next,
results: meta.results.map((r: Record<string, unknown>) => {
const { diff, ...rest } = r
if (!r.filediff || typeof r.filediff !== "object") return rest
const fd = r.filediff as Record<string, unknown>
const { before, after, patch, ...file } = fd
return { ...rest, filediff: { ...file, ...withPatch(patch) } }
}),
}
changed = true
@@ -0,0 +1,129 @@
// kilocode_change - new file
import { describe, expect, test } from "bun:test"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { Snapshot } from "../../src/snapshot"
const sessionID = SessionID.make("session")
const 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"
function blob(size: number) {
return "x".repeat(size)
}
function part(tool: string, metadata: Record<string, unknown>): MessageV2.Part {
return {
id: PartID.make(`p-${tool}`),
sessionID,
messageID: MessageID.make("m-assistant"),
type: "tool",
callID: `call-${tool}`,
tool,
state: {
status: "completed",
input: {},
output: "ok",
title: tool,
metadata,
time: { start: 0, end: 1 },
},
} as MessageV2.Part
}
describe("session message metadata stripping", () => {
test("keeps bounded edit filediff patches and strips heavy fields", () => {
const input = part("edit", {
diff: blob(200_000),
filediff: {
file: "a.ts",
patch,
before: blob(200_000),
after: blob(200_000),
additions: 1,
deletions: 1,
},
diagnostics: {},
})
const stripped = MessageV2.stripPartMetadata(input) as Extract<MessageV2.Part, { type: "tool" }>
const meta = stripped.state.status === "completed" ? stripped.state.metadata : {}
expect(meta.diff).toBeUndefined()
expect(meta.filediff.before).toBeUndefined()
expect(meta.filediff.after).toBeUndefined()
expect(meta.filediff.patch).toBe(patch)
expect(JSON.stringify(stripped).length).toBeLessThan(10_000)
})
test("keeps bounded write filediff patches and strips heavy fields", () => {
const input = part("write", {
diff: blob(200_000),
filediff: {
file: "README.md",
patch,
before: blob(200_000),
after: blob(200_000),
additions: 1,
deletions: 1,
},
diagnostics: {},
})
const stripped = MessageV2.stripPartMetadata(input) as Extract<MessageV2.Part, { type: "tool" }>
const meta = stripped.state.status === "completed" ? stripped.state.metadata : {}
expect(meta.diff).toBeUndefined()
expect(meta.filediff.before).toBeUndefined()
expect(meta.filediff.after).toBeUndefined()
expect(meta.filediff.patch).toBe(patch)
expect(JSON.stringify(stripped).length).toBeLessThan(10_000)
})
test("keeps bounded apply_patch per-file patches and strips heavy fields", () => {
const input = part("apply_patch", {
diff: blob(200_000),
files: [
{
filePath: "/tmp/a.ts",
relativePath: "a.ts",
type: "update",
patch,
before: blob(200_000),
after: blob(200_000),
additions: 1,
deletions: 1,
},
],
diagnostics: {},
})
const stripped = MessageV2.stripPartMetadata(input) as Extract<MessageV2.Part, { type: "tool" }>
const meta = stripped.state.status === "completed" ? stripped.state.metadata : {}
expect(meta.diff).toBeUndefined()
expect(meta.files[0].before).toBeUndefined()
expect(meta.files[0].after).toBeUndefined()
expect(meta.files[0].patch).toBe(patch)
expect(JSON.stringify(stripped).length).toBeLessThan(10_000)
})
test("drops oversized tool patches from hydrated session metadata", () => {
const wide = `Index: a.ts\n--- a.ts\n+++ a.ts\n@@ -1,1 +1,1 @@\n-${blob(Snapshot.MAX_DIFF_SIZE)}\n+${blob(Snapshot.MAX_DIFF_SIZE)}\n`
const input = part("apply_patch", {
files: [
{
filePath: "/tmp/a.ts",
relativePath: "a.ts",
type: "update",
patch: wide,
additions: 1,
deletions: 1,
},
],
})
const stripped = MessageV2.stripPartMetadata(input) as Extract<MessageV2.Part, { type: "tool" }>
const meta = stripped.state.status === "completed" ? stripped.state.metadata : {}
expect(meta.files[0].patch).toBeUndefined()
expect(JSON.stringify(stripped).length).toBeLessThan(10_000)
})
})
+17 -5
View File
@@ -14,9 +14,18 @@ type LegacyDiff = {
type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff
// kilocode_change start - expose patch text extraction without building FileDiffMetadata on the UI thread
export type DiffText = {
before: string
after: string
patch: string
}
export type ViewDiff = {
file: string
patch: string
before: string // kilocode_change
after: string // kilocode_change
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
@@ -25,7 +34,7 @@ export type ViewDiff = {
const cache = new Map<string, FileDiffMetadata>()
function patch(diff: ReviewDiff) {
export function contents(diff: ReviewDiff): DiffText {
if (typeof diff.patch === "string") {
const [patch] = parsePatch(diff.patch)
@@ -46,7 +55,7 @@ function patch(diff: ReviewDiff) {
}
}
return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch }
return { before: beforeLines.join("\n") + "\n", after: afterLines.join("\n") + "\n", patch: diff.patch }
}
return {
before: "before" in diff && typeof diff.before === "string" ? diff.before : "",
@@ -64,6 +73,7 @@ function patch(diff: ReviewDiff) {
),
}
}
// kilocode_change end
function file(file: string, patch: string, before: string, after: string) {
const hit = cache.get(patch)
@@ -75,10 +85,12 @@ function file(file: string, patch: string, before: string, after: string) {
}
export function normalize(diff: ReviewDiff): ViewDiff {
const next = patch(diff)
const next = contents(diff) // kilocode_change
return {
file: diff.file,
patch: next.patch,
file: diff.file, // kilocode_change
patch: next.patch, // kilocode_change
before: next.before, // kilocode_change
after: next.after, // kilocode_change
additions: diff.additions,
deletions: diff.deletions,
status: diff.status,