Skip to content
Closed
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
7 changes: 7 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,14 @@ export interface ExtensionMessage {
| "skills"
| "rules"
| "fileContent"
| "originalContent"
| "rooHistoryImportProgress"
| "themeFixtureProbeRequest"
text?: string
/** For fileContent: { path, content, error? } */
fileContent?: { path: string; content: string | null; error?: string }
/** For originalContent: the pre-edit file content of the requested tool message (null when unavailable) */
originalContentInfo?: { ts: number; messageId?: string; taskId?: string; content: string | null }
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
checkpointWarning?: {
type: "WAIT_TIMEOUT" | "INIT_TIMEOUT"
Expand Down Expand Up @@ -498,6 +501,7 @@ export interface WebviewMessage {
| "saveImage"
| "openFile"
| "readFileContent"
| "readOriginalContent"
| "openMention"
| "cancelTask"
| "cancelAutoApproval"
Expand Down Expand Up @@ -696,6 +700,7 @@ export interface WebviewMessage {
ids?: string[]
terminalOperation?: "continue" | "abort"
messageTs?: number
messageId?: string
restoreCheckpoint?: boolean
historyPreviewCollapsed?: boolean
filters?: { type?: string; search?: string; tags?: string[] }
Expand Down Expand Up @@ -858,6 +863,8 @@ export interface ClineSayTool {
content?: string
// Original file content before first edit (for merged diff display in FileChangesPanel)
originalContent?: string
// Length of the originalContent the extension left out of the webview state (request it with readOriginalContent)
originalContentLength?: number
// Unified diff statistics computed by the extension
diffStats?: { added: number; removed: number }
regex?: string
Expand Down
129 changes: 129 additions & 0 deletions scripts/gray-screen/analyze-session.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env node
// Measures what a saved Zoo Code task is made of, to see what the webview has to hold: sizes only, never prints message content.
//
// node scripts/gray-screen/analyze-session.mjs [--storage <globalStorage>] [--top 15] [--task <id>] [--include-generated true]
//
// Per task: file size, message counts, bytes per message kind and per tool, bytes per JSON field of tool payloads
// (originalContent / content / diff ...), share of strings V8 stores as 2 bytes/char (any char above U+00FF), and the
// longest run of consecutive file-edit asks that ChatView's batchNearby would merge into one message.
import fs from "node:fs"
import os from "node:os"
import path from "node:path"

const args = Object.fromEntries(
process.argv.slice(2).reduce((acc, cur, i, all) => {
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]])
return acc
}, []),
)
const storage =
args["storage"] ?? path.join(os.homedir(), ".vscode-server", "data", "User", "globalStorage", "codemate.zoo-code")
const top = Number(args["top"] ?? 15)
const includeGenerated = args["include-generated"] === "true"
const tasksDir = path.join(storage, "tasks")

const EDIT_TOOLS = new Set(["editedExistingFile", "appliedDiff", "newFileCreated", "insertContent", "searchAndReplace"])
const BOUNDARY_SAY = new Set(["user_feedback", "user_feedback_diff", "completion_result", "checkpoint_saved", "error", "condense_context", "codebase_search_result"])
const NON_LATIN1 = /[^\u0000-ÿ]/
const mb = (n) => (n / 1048576).toFixed(1)

const isIgnorable = (m) => m.type === "say" && (m.say === "api_req_started" || (m.say === "text" && !m.text?.trim()) || m.say === "reasoning")
const isBoundary = (m) => m.type === "say" && (BOUNDARY_SAY.has(m.say) || (m.say === "text" && !!m.text?.trim()))

function analyze(taskId) {
const file = path.join(tasksDir, taskId, "ui_messages.json")
const raw = fs.readFileSync(file, "utf8")
const messages = JSON.parse(raw)
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 }

const add = (obj, key, bytes) => {
const e = (obj[key] ??= { n: 0, bytes: 0 })
e.n++
e.bytes += bytes
}
const classify = (str) => {
if (NON_LATIN1.test(str)) r.twoByteBytes += str.length * 2
else r.latin1Bytes += str.length
}

for (const m of messages) {
const text = typeof m.text === "string" ? m.text : ""
add(r.kinds, `${m.type}:${m.say ?? m.ask ?? "?"}`, text.length)
Comment thread
daewoongoh marked this conversation as resolved.
if (text) classify(text)
if (Array.isArray(m.images)) for (const img of m.images) r.imageBytes += typeof img === "string" ? img.length : 0
if (m.type === "ask" && m.ask === "tool" && text) {
try {
const t = JSON.parse(text)
add(r.tools, String(t.tool), text.length)
for (const [k, v] of Object.entries(t)) add(r.fields, k, typeof v === "string" ? v.length : JSON.stringify(v)?.length ?? 0)
} catch {}
}
}

// longest run of consecutive edit asks that batchNearby merges (same rules as batchNearby.ts)
const isEditAsk = (m) => {
if (m.type !== "ask" || m.ask !== "tool" || !m.text) return false
try {
const t = JSON.parse(m.text)
return EDIT_TOOLS.has(t.tool) && !t.batchDiffs
} catch {
return false
}
}
const items = messages.slice(1)
for (let i = 0; i < items.length; ) {
if (isBoundary(items[i])) { i++; continue }
if (!isEditAsk(items[i])) { i++; continue }
let j = i + 1, len = 1, bytes = items[i].text.length
while (j < items.length) {
if (isBoundary(items[j])) break
if (isEditAsk(items[j])) { len++; bytes += items[j].text.length; j++ }
else if (isIgnorable(items[j])) j++
else break
}
if (len > 1) r.runs2plus++
if (len > r.maxRun) { r.maxRun = len; r.maxRunBytes = bytes }
i = j
}
return r
}

const taskMeta = (id) => {
try { return JSON.parse(fs.readFileSync(path.join(tasksDir, id, "history_item.json"), "utf8")) } catch { return {} }
}
const ids = args["task"] ? [args["task"]] : fs.readdirSync(tasksDir).filter((id) => fs.existsSync(path.join(tasksDir, id, "ui_messages.json")))
const sizes = ids.map((id) => ({ id, size: fs.statSync(path.join(tasksDir, id, "ui_messages.json")).size, generated: (taskMeta(id).task ?? "").startsWith("[LOAD TEST]") }))
const real = sizes.filter((s) => includeGenerated || !s.generated)

const sorted = real.map((s) => s.size).sort((a, b) => a - b)
const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? 0
console.log(`storage: ${storage}`)
console.log(`tasks: ${ids.length} (excluded ${sizes.length - real.length} generated [LOAD TEST] tasks${includeGenerated ? " - included" : ""}); analyzed set: ${real.length}`)
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}`)

const results = real.sort((a, b) => b.size - a.size).slice(0, top).map((s) => analyze(s.id))
console.log(`\nTop ${results.length} tasks by ui_messages.json size (sizes only, no content):`)
console.log("size(MB) msgs toolTxt(MB) editTools topField(share) 2byte% imgMB maxBatchRun(bytes MB) task")
for (const r of results) {
const toolBytes = Object.values(r.tools).reduce((a, b) => a + b.bytes, 0)
const editCount = Object.entries(r.tools).filter(([k]) => EDIT_TOOLS.has(k)).reduce((a, [, v]) => a + v.n, 0)
const fieldTotal = Object.values(r.fields).reduce((a, b) => a + b.bytes, 0) || 1
const [topKey, topVal] = Object.entries(r.fields).sort((a, b) => b[1].bytes - a[1].bytes)[0] ?? ["-", { bytes: 0 }]
const text = r.latin1Bytes + r.twoByteBytes || 1
const meta = taskMeta(r.taskId)
console.log(
`${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)}]`,
Comment thread
daewoongoh marked this conversation as resolved.
)
}

const biggest = results[0]
if (biggest) {
console.log(`\nBreakdown of the largest analyzed task [${biggest.taskId.slice(0, 8)}], ${mb(biggest.fileBytes)}MB, ${biggest.count} messages:`)
const show = (title, obj, n = 8) => {
console.log(` ${title}`)
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`)
}
show("by message kind (text bytes):", biggest.kinds)
show("by tool (payload bytes):", biggest.tools)
show("by tool payload field (string chars):", biggest.fields)
}
124 changes: 124 additions & 0 deletions scripts/gray-screen/generate-large-task.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env node
// Generates a synthetic task with thousands of messages to reproduce webview
// memory pressure (gray screen / OOM). Usage:
// node scripts/gray-screen/generate-large-task.mjs [--messages 5000] [--text-bytes 800] [--tool-bytes <text-bytes>]
// [--tool-kind mixed|newFileCreated|appliedDiff|readFile|listFilesRecursive] [--batchable true] [--two-byte true]
// [--image-every 0] [--image-kb 200] [--storage <globalStoragePath>] [--workspace <path>]
// Then reload VS Code and open the task named "[LOAD TEST] ..." from history.
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import crypto from "node:crypto"

const args = Object.fromEntries(
process.argv.slice(2).reduce((acc, cur, i, all) => {
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]])
return acc
}, []),
)

const messageCount = Number(args["messages"] ?? 5000)
const textBytes = Number(args["text-bytes"] ?? 800)
const toolBytes = Number(args["tool-bytes"] ?? textBytes)
const toolKind = args["tool-kind"] ?? "mixed"
const batchable = args["batchable"] === "true"
const twoByte = args["two-byte"] === "true" // Korean chars in tool text -> UTF-16 strings (2 bytes/char) in V8
const imageEvery = Number(args["image-every"] ?? 0)
const imageKb = Number(args["image-kb"] ?? 200)
const workspace = args["workspace"] ?? process.cwd()
const storageCandidates = [
path.join(os.homedir(), ".vscode-server", "data", "User", "globalStorage", "codemate.zoo-code"),
path.join(os.homedir(), ".config", "Code", "User", "globalStorage", "codemate.zoo-code"),
]
const storage = args["storage"] ?? storageCandidates.find((p) => fs.existsSync(p)) ?? storageCandidates[1]

const taskId = crypto.randomUUID()
const taskDir = path.join(storage, "tasks", taskId)
fs.mkdirSync(taskDir, { recursive: true })

const filler = (n, seed) => {
const base = `line ${seed}: ${twoByte ? "\uD55C\uAE00 " : ""}The quick brown fox jumps over the lazy dog. `
return base.repeat(Math.ceil(n / base.length)).slice(0, n)
}
const fakeImage = (kb) => `data:image/png;base64,${crypto.randomBytes(kb * 768).toString("base64")}`

// Large tool payloads (new files, diffs) are what the webview re-parses on every streamed chunk.
const KINDS = ["newFileCreated", "appliedDiff", "readFile", "listFilesRecursive"]
const toolPayload = (round) => {
const kind = toolKind === "mixed" ? KINDS[round % KINDS.length] : toolKind
const file = `src/gen/file-${round}.ts`
switch (kind) {
case "newFileCreated":
return { tool: "newFileCreated", path: file, content: filler(toolBytes, round) }
case "appliedDiff":
return { tool: "appliedDiff", path: file, diff: filler(toolBytes, round), diffStats: { added: 12, removed: 4 } }
case "listFilesRecursive":
return { tool: "listFilesRecursive", path: "src", content: filler(Math.min(toolBytes, 2000), round) }
default:
return { tool: "readFile", path: file, content: filler(Math.min(toolBytes, 400), round) }
}
}

const taskText = `[LOAD TEST] ${messageCount} messages`
const start = Date.now() - messageCount * 1000
const messages = [{ ts: start, type: "say", say: "text", text: taskText }]
const apiHistory = [{ role: "user", content: [{ type: "text", text: `<task>\n${taskText}\n</task>` }], ts: start }]

let ts = start + 1
let round = 0
while (messages.length < messageCount) {
round++
const images = imageEvery > 0 && round % imageEvery === 0 ? [fakeImage(imageKb)] : undefined
messages.push({
ts: ts++,
type: "say",
say: "api_req_started",
text: JSON.stringify({ apiProtocol: "openai", tokensIn: 1000, tokensOut: 200, cost: 0.001 }),
})
// batchable: tool-only turns (no visible text/feedback between edits), which ChatView merges into one giant batch message
if (!batchable) messages.push({ ts: ts++, type: "say", say: "text", text: filler(textBytes, round), images })
messages.push({
ts: ts++,
type: "ask",
ask: "tool",
text: JSON.stringify(toolPayload(round)),
isAnswered: true,
})
if (!batchable) messages.push({ ts: ts++, type: "say", say: "user_feedback", text: `ok ${round}` })
apiHistory.push(
{ role: "assistant", content: [{ type: "text", text: filler(textBytes, round) }], ts },
{ role: "user", content: [{ type: "text", text: `ok ${round}` }], ts },
)
}
messages.length = messageCount

const historyItem = {
id: taskId,
number: 9999,
ts: Date.now(),
task: taskText,
tokensIn: round * 1000,
tokensOut: round * 200,
totalCost: 0,
workspace,
status: "completed",
}

// Write every file under a temporary name and rename only after all writes succeeded, so an interrupted run
// (or a full disk) never leaves a truncated or partial task; history_item.json goes last.
const taskFiles = [
["ui_messages.json", messages],
["api_conversation_history.json", apiHistory],
["history_item.json", historyItem],
]
try {
for (const [name, value] of taskFiles) fs.writeFileSync(path.join(taskDir, `${name}.tmp`), JSON.stringify(value))
for (const [name] of taskFiles) fs.renameSync(path.join(taskDir, `${name}.tmp`), path.join(taskDir, name))
} catch (error) {
fs.rmSync(taskDir, { recursive: true, force: true })
throw error
}

const mb = (f) => (fs.statSync(path.join(taskDir, f)).size / 1048576).toFixed(1)
console.log(`Task ${taskId}: ${messages.length} messages, ui_messages.json ${mb("ui_messages.json")} MB`)
console.log(`Written to ${taskDir}\nReload VS Code, then open "${taskText}" from history.`)
Loading
Loading