Skip to content

Commit 71c8fb6

Browse files
committed
fix(webview): omit originalContent from webview messages and fetch on demand
Strip originalContent (the whole pre-edit file) from file-edit tool messages before they are posted to the webview, and let FileChangesPanel request it on demand by messageId and taskId. This keeps the webview heap from growing with the total size of edited files, which could end in a gray screen (OOM). Also adds the scripts/gray-screen heap-measurement tooling.
1 parent c38db3e commit 71c8fb6

17 files changed

Lines changed: 2294 additions & 14 deletions

‎packages/types/src/vscode-extension-host.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,14 @@ export interface ExtensionMessage {
106106
| "skills"
107107
| "rules"
108108
| "fileContent"
109+
| "originalContent"
109110
| "rooHistoryImportProgress"
110111
| "themeFixtureProbeRequest"
111112
text?: string
112113
/** For fileContent: { path, content, error? } */
113114
fileContent?: { path: string; content: string | null; error?: string }
115+
/** For originalContent: the pre-edit file content of the requested tool message (null when unavailable) */
116+
originalContentInfo?: { ts: number; messageId?: string; taskId?: string; content: string | null }
114117
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
115118
checkpointWarning?: {
116119
type: "WAIT_TIMEOUT" | "INIT_TIMEOUT"
@@ -498,6 +501,7 @@ export interface WebviewMessage {
498501
| "saveImage"
499502
| "openFile"
500503
| "readFileContent"
504+
| "readOriginalContent"
501505
| "openMention"
502506
| "cancelTask"
503507
| "cancelAutoApproval"
@@ -696,6 +700,7 @@ export interface WebviewMessage {
696700
ids?: string[]
697701
terminalOperation?: "continue" | "abort"
698702
messageTs?: number
703+
messageId?: string
699704
restoreCheckpoint?: boolean
700705
historyPreviewCollapsed?: boolean
701706
filters?: { type?: string; search?: string; tags?: string[] }
@@ -858,6 +863,8 @@ export interface ClineSayTool {
858863
content?: string
859864
// Original file content before first edit (for merged diff display in FileChangesPanel)
860865
originalContent?: string
866+
// Length of the originalContent the extension left out of the webview state (request it with readOriginalContent)
867+
originalContentLength?: number
861868
// Unified diff statistics computed by the extension
862869
diffStats?: { added: number; removed: number }
863870
regex?: string
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#!/usr/bin/env node
2+
// Measures what a saved Zoo Code task is made of, to see what the webview has to hold: sizes only, never prints message content.
3+
//
4+
// node scripts/gray-screen/analyze-session.mjs [--storage <globalStorage>] [--top 15] [--task <id>] [--include-generated true]
5+
//
6+
// Per task: file size, message counts, bytes per message kind and per tool, bytes per JSON field of tool payloads
7+
// (originalContent / content / diff ...), share of strings V8 stores as 2 bytes/char (any char above U+00FF), and the
8+
// longest run of consecutive file-edit asks that ChatView's batchNearby would merge into one message.
9+
import fs from "node:fs"
10+
import os from "node:os"
11+
import path from "node:path"
12+
13+
const args = Object.fromEntries(
14+
process.argv.slice(2).reduce((acc, cur, i, all) => {
15+
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]])
16+
return acc
17+
}, []),
18+
)
19+
const storage =
20+
args["storage"] ?? path.join(os.homedir(), ".vscode-server", "data", "User", "globalStorage", "codemate.zoo-code")
21+
const top = Number(args["top"] ?? 15)
22+
const includeGenerated = args["include-generated"] === "true"
23+
const tasksDir = path.join(storage, "tasks")
24+
25+
const EDIT_TOOLS = new Set(["editedExistingFile", "appliedDiff", "newFileCreated", "insertContent", "searchAndReplace"])
26+
const BOUNDARY_SAY = new Set(["user_feedback", "user_feedback_diff", "completion_result", "checkpoint_saved", "error", "condense_context", "codebase_search_result"])
27+
const NON_LATIN1 = /[^\u0000-ÿ]/
28+
const mb = (n) => (n / 1048576).toFixed(1)
29+
30+
const isIgnorable = (m) => m.type === "say" && (m.say === "api_req_started" || (m.say === "text" && !m.text?.trim()) || m.say === "reasoning")
31+
const isBoundary = (m) => m.type === "say" && (BOUNDARY_SAY.has(m.say) || (m.say === "text" && !!m.text?.trim()))
32+
33+
function analyze(taskId) {
34+
const file = path.join(tasksDir, taskId, "ui_messages.json")
35+
const raw = fs.readFileSync(file, "utf8")
36+
const messages = JSON.parse(raw)
37+
const r = { taskId, fileBytes: Buffer.byteLength(raw), count: messages.length, kinds: {}, tools: {}, fields: {}, imageBytes: 0, twoByteBytes: 0, latin1Bytes: 0, maxRun: 0, maxRunBytes: 0, runs2plus: 0 }
38+
39+
const add = (obj, key, bytes) => {
40+
const e = (obj[key] ??= { n: 0, bytes: 0 })
41+
e.n++
42+
e.bytes += bytes
43+
}
44+
const classify = (str) => {
45+
if (NON_LATIN1.test(str)) r.twoByteBytes += str.length * 2
46+
else r.latin1Bytes += str.length
47+
}
48+
49+
for (const m of messages) {
50+
const text = typeof m.text === "string" ? m.text : ""
51+
add(r.kinds, `${m.type}:${m.say ?? m.ask ?? "?"}`, text.length)
52+
if (text) classify(text)
53+
if (Array.isArray(m.images)) for (const img of m.images) r.imageBytes += typeof img === "string" ? img.length : 0
54+
if (m.type === "ask" && m.ask === "tool" && text) {
55+
try {
56+
const t = JSON.parse(text)
57+
add(r.tools, String(t.tool), text.length)
58+
for (const [k, v] of Object.entries(t)) add(r.fields, k, typeof v === "string" ? v.length : JSON.stringify(v)?.length ?? 0)
59+
} catch {}
60+
}
61+
}
62+
63+
// longest run of consecutive edit asks that batchNearby merges (same rules as batchNearby.ts)
64+
const isEditAsk = (m) => {
65+
if (m.type !== "ask" || m.ask !== "tool" || !m.text) return false
66+
try {
67+
const t = JSON.parse(m.text)
68+
return EDIT_TOOLS.has(t.tool) && !t.batchDiffs
69+
} catch {
70+
return false
71+
}
72+
}
73+
const items = messages.slice(1)
74+
for (let i = 0; i < items.length; ) {
75+
if (isBoundary(items[i])) { i++; continue }
76+
if (!isEditAsk(items[i])) { i++; continue }
77+
let j = i + 1, len = 1, bytes = items[i].text.length
78+
while (j < items.length) {
79+
if (isBoundary(items[j])) break
80+
if (isEditAsk(items[j])) { len++; bytes += items[j].text.length; j++ }
81+
else if (isIgnorable(items[j])) j++
82+
else break
83+
}
84+
if (len > 1) r.runs2plus++
85+
if (len > r.maxRun) { r.maxRun = len; r.maxRunBytes = bytes }
86+
i = j
87+
}
88+
return r
89+
}
90+
91+
const taskMeta = (id) => {
92+
try { return JSON.parse(fs.readFileSync(path.join(tasksDir, id, "history_item.json"), "utf8")) } catch { return {} }
93+
}
94+
const ids = args["task"] ? [args["task"]] : fs.readdirSync(tasksDir).filter((id) => fs.existsSync(path.join(tasksDir, id, "ui_messages.json")))
95+
const sizes = ids.map((id) => ({ id, size: fs.statSync(path.join(tasksDir, id, "ui_messages.json")).size, generated: (taskMeta(id).task ?? "").startsWith("[LOAD TEST]") }))
96+
const real = sizes.filter((s) => includeGenerated || !s.generated)
97+
98+
const sorted = real.map((s) => s.size).sort((a, b) => a - b)
99+
const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? 0
100+
console.log(`storage: ${storage}`)
101+
console.log(`tasks: ${ids.length} (excluded ${sizes.length - real.length} generated [LOAD TEST] tasks${includeGenerated ? " - included" : ""}); analyzed set: ${real.length}`)
102+
console.log(`ui_messages.json size: p50=${mb(pct(0.5))}MB p90=${mb(pct(0.9))}MB p99=${mb(pct(0.99))}MB max=${mb(sorted.at(-1) ?? 0)}MB; over 10MB: ${sorted.filter((s) => s > 10 * 1048576).length}, over 5MB: ${sorted.filter((s) => s > 5 * 1048576).length}`)
103+
104+
const results = real.sort((a, b) => b.size - a.size).slice(0, top).map((s) => analyze(s.id))
105+
console.log(`\nTop ${results.length} tasks by ui_messages.json size (sizes only, no content):`)
106+
console.log("size(MB) msgs toolTxt(MB) editTools topField(share) 2byte% imgMB maxBatchRun(bytes MB) task")
107+
for (const r of results) {
108+
const toolBytes = Object.values(r.tools).reduce((a, b) => a + b.bytes, 0)
109+
const editCount = Object.entries(r.tools).filter(([k]) => EDIT_TOOLS.has(k)).reduce((a, [, v]) => a + v.n, 0)
110+
const fieldTotal = Object.values(r.fields).reduce((a, b) => a + b.bytes, 0) || 1
111+
const [topKey, topVal] = Object.entries(r.fields).sort((a, b) => b[1].bytes - a[1].bytes)[0] ?? ["-", { bytes: 0 }]
112+
const text = r.latin1Bytes + r.twoByteBytes || 1
113+
const meta = taskMeta(r.taskId)
114+
console.log(
115+
`${mb(r.fileBytes).padStart(7)} ${String(r.count).padStart(5)} ${mb(toolBytes).padStart(9)} ${String(editCount).padStart(9)} ${`${topKey} ${(100 * topVal.bytes / fieldTotal).toFixed(0)}%`.padEnd(30)} ${(100 * r.twoByteBytes / text).toFixed(0).padStart(5)}% ${mb(r.imageBytes).padStart(5)} ${`${r.maxRun} (${mb(r.maxRunBytes)})`.padEnd(20)} ${(meta.task ?? "").slice(0, 28).replace(/\s+/g, " ")} [${r.taskId.slice(0, 8)}]`,
116+
)
117+
}
118+
119+
const biggest = results[0]
120+
if (biggest) {
121+
console.log(`\nBreakdown of the largest analyzed task [${biggest.taskId.slice(0, 8)}], ${mb(biggest.fileBytes)}MB, ${biggest.count} messages:`)
122+
const show = (title, obj, n = 8) => {
123+
console.log(` ${title}`)
124+
for (const [k, v] of Object.entries(obj).sort((a, b) => b[1].bytes - a[1].bytes).slice(0, n)) console.log(` ${k.padEnd(28)} n=${String(v.n).padStart(5)} ${mb(v.bytes).padStart(7)}MB`)
125+
}
126+
show("by message kind (text bytes):", biggest.kinds)
127+
show("by tool (payload bytes):", biggest.tools)
128+
show("by tool payload field (string chars):", biggest.fields)
129+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env node
2+
// Generates a synthetic task with thousands of messages to reproduce webview
3+
// memory pressure (gray screen / OOM). Usage:
4+
// node scripts/gray-screen/generate-large-task.mjs [--messages 5000] [--text-bytes 800] [--tool-bytes <text-bytes>]
5+
// [--tool-kind mixed|newFileCreated|appliedDiff|readFile|listFilesRecursive] [--batchable true] [--two-byte true]
6+
// [--image-every 0] [--image-kb 200] [--storage <globalStoragePath>] [--workspace <path>]
7+
// Then reload VS Code and open the task named "[LOAD TEST] ..." from history.
8+
import fs from "node:fs"
9+
import os from "node:os"
10+
import path from "node:path"
11+
import crypto from "node:crypto"
12+
13+
const args = Object.fromEntries(
14+
process.argv.slice(2).reduce((acc, cur, i, all) => {
15+
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]])
16+
return acc
17+
}, []),
18+
)
19+
20+
const messageCount = Number(args["messages"] ?? 5000)
21+
const textBytes = Number(args["text-bytes"] ?? 800)
22+
const toolBytes = Number(args["tool-bytes"] ?? textBytes)
23+
const toolKind = args["tool-kind"] ?? "mixed"
24+
const batchable = args["batchable"] === "true"
25+
const twoByte = args["two-byte"] === "true" // Korean chars in tool text -> UTF-16 strings (2 bytes/char) in V8
26+
const imageEvery = Number(args["image-every"] ?? 0)
27+
const imageKb = Number(args["image-kb"] ?? 200)
28+
const workspace = args["workspace"] ?? process.cwd()
29+
const storageCandidates = [
30+
path.join(os.homedir(), ".vscode-server", "data", "User", "globalStorage", "codemate.zoo-code"),
31+
path.join(os.homedir(), ".config", "Code", "User", "globalStorage", "codemate.zoo-code"),
32+
]
33+
const storage = args["storage"] ?? storageCandidates.find((p) => fs.existsSync(p)) ?? storageCandidates[1]
34+
35+
const taskId = crypto.randomUUID()
36+
const taskDir = path.join(storage, "tasks", taskId)
37+
fs.mkdirSync(taskDir, { recursive: true })
38+
39+
const filler = (n, seed) => {
40+
const base = `line ${seed}: ${twoByte ? "\uD55C\uAE00 " : ""}The quick brown fox jumps over the lazy dog. `
41+
return base.repeat(Math.ceil(n / base.length)).slice(0, n)
42+
}
43+
const fakeImage = (kb) => `data:image/png;base64,${crypto.randomBytes(kb * 768).toString("base64")}`
44+
45+
// Large tool payloads (new files, diffs) are what the webview re-parses on every streamed chunk.
46+
const KINDS = ["newFileCreated", "appliedDiff", "readFile", "listFilesRecursive"]
47+
const toolPayload = (round) => {
48+
const kind = toolKind === "mixed" ? KINDS[round % KINDS.length] : toolKind
49+
const file = `src/gen/file-${round}.ts`
50+
switch (kind) {
51+
case "newFileCreated":
52+
return { tool: "newFileCreated", path: file, content: filler(toolBytes, round) }
53+
case "appliedDiff":
54+
return { tool: "appliedDiff", path: file, diff: filler(toolBytes, round), diffStats: { added: 12, removed: 4 } }
55+
case "listFilesRecursive":
56+
return { tool: "listFilesRecursive", path: "src", content: filler(Math.min(toolBytes, 2000), round) }
57+
default:
58+
return { tool: "readFile", path: file, content: filler(Math.min(toolBytes, 400), round) }
59+
}
60+
}
61+
62+
const taskText = `[LOAD TEST] ${messageCount} messages`
63+
const start = Date.now() - messageCount * 1000
64+
const messages = [{ ts: start, type: "say", say: "text", text: taskText }]
65+
const apiHistory = [{ role: "user", content: [{ type: "text", text: `<task>\n${taskText}\n</task>` }], ts: start }]
66+
67+
let ts = start + 1
68+
let round = 0
69+
while (messages.length < messageCount) {
70+
round++
71+
const images = imageEvery > 0 && round % imageEvery === 0 ? [fakeImage(imageKb)] : undefined
72+
messages.push({
73+
ts: ts++,
74+
type: "say",
75+
say: "api_req_started",
76+
text: JSON.stringify({ apiProtocol: "openai", tokensIn: 1000, tokensOut: 200, cost: 0.001 }),
77+
})
78+
// batchable: tool-only turns (no visible text/feedback between edits), which ChatView merges into one giant batch message
79+
if (!batchable) messages.push({ ts: ts++, type: "say", say: "text", text: filler(textBytes, round), images })
80+
messages.push({
81+
ts: ts++,
82+
type: "ask",
83+
ask: "tool",
84+
text: JSON.stringify(toolPayload(round)),
85+
isAnswered: true,
86+
})
87+
if (!batchable) messages.push({ ts: ts++, type: "say", say: "user_feedback", text: `ok ${round}` })
88+
apiHistory.push(
89+
{ role: "assistant", content: [{ type: "text", text: filler(textBytes, round) }], ts },
90+
{ role: "user", content: [{ type: "text", text: `ok ${round}` }], ts },
91+
)
92+
}
93+
messages.length = messageCount
94+
95+
const historyItem = {
96+
id: taskId,
97+
number: 9999,
98+
ts: Date.now(),
99+
task: taskText,
100+
tokensIn: round * 1000,
101+
tokensOut: round * 200,
102+
totalCost: 0,
103+
workspace,
104+
status: "completed",
105+
}
106+
107+
// Write every file under a temporary name and rename only after all writes succeeded, so an interrupted run
108+
// (or a full disk) never leaves a truncated or partial task; history_item.json goes last.
109+
const taskFiles = [
110+
["ui_messages.json", messages],
111+
["api_conversation_history.json", apiHistory],
112+
["history_item.json", historyItem],
113+
]
114+
try {
115+
for (const [name, value] of taskFiles) fs.writeFileSync(path.join(taskDir, `${name}.tmp`), JSON.stringify(value))
116+
for (const [name] of taskFiles) fs.renameSync(path.join(taskDir, `${name}.tmp`), path.join(taskDir, name))
117+
} catch (error) {
118+
fs.rmSync(taskDir, { recursive: true, force: true })
119+
throw error
120+
}
121+
122+
const mb = (f) => (fs.statSync(path.join(taskDir, f)).size / 1048576).toFixed(1)
123+
console.log(`Task ${taskId}: ${messages.length} messages, ui_messages.json ${mb("ui_messages.json")} MB`)
124+
console.log(`Written to ${taskDir}\nReload VS Code, then open "${taskText}" from history.`)

0 commit comments

Comments
 (0)