Repository navigation
fix(webview): omit originalContent from webview messages and fetch on demand #1886
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
daewoongoh
wants to merge
4
commits into
Zoo-Code-Org:main
from
daewoongoh:fix/webview-omit-original-content
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
2ece75b
feat(webview): omit originalContent from webview messages and fetch o…
daewoongoh 4d13b64
fix(webview): keep message metadata fresh and identify originals by m…
daewoongoh a77d28c
fix(webview): reset FileChangesPanel original-content state when task…
daewoongoh 9ea9cdd
fix: harden gray-screen tooling and dedupe in-flight original-content…
daewoongoh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| 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)}]`, | ||
|
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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.`) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.