UI: cap Control UI chat history rendering by char budget#56783
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 19a10f50e4
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Greptile SummaryThis PR adds a 240k-character budget to the history windowing logic so that the Control UI caps rendered message payloads by size in addition to the existing 200-message count limit, addressing UI freezes on sessions with very large tool outputs. The overall approach is sound — iterating backwards from the newest message and stopping when either cap is reached is correct, and the "always show at least one message" guard ( Issues found:
Confidence Score: 4/5Safe to merge for correctness, but the char-budget fix may not protect against the specific freeze scenario (large tool-result There is one P1 finding:
|
| Filename | Overview |
|---|---|
| ui/src/ui/views/chat.ts | Introduces resolveHistoryStartIndex with both a 200-message count cap and a 240k-char budget. The budget logic correctly ensures at least one message is always shown, and the notice now reflects the actual visible count. However, estimateMessageRenderChars only inspects normalized.text and normalized.args — it misses tool-result payloads stored in item.content, which is the API format for the large tool outputs described as the root cause. |
| ui/src/ui/views/chat.test.ts | Adds two new tests: count-cap (205 messages with short content) and char-budget-cap (6 messages with 100k-char string content). Both pass because messages use top-level string content (mapped to normalized.text). Missing coverage for array-content tool-result messages where the payload is in item.content. |
Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/ui/views/chat.ts
Line: 1413-1426
Comment:
**Char estimation misses tool-result `content` field — the primary large-payload case**
`normalizeMessage` maps `item.text` → `normalized.text` and `resolveToolBlockArgs(item)` (i.e. `item.args ?? item.arguments ?? item.input`) → `normalized.args`. It does **not** map `item.content` to any field. But tool result blocks in the Anthropic API format — the exact messages the PR cites as the root cause — look like:
```json
{ "type": "tool_result", "tool_use_id": "...", "content": "... 50 000 char output ..." }
```
For such an item, `normalizeMessage` produces `{ text: undefined, args: undefined }`, so `estimateMessageRenderChars` returns `Math.max(0, 1) = 1` regardless of the actual payload size.
By contrast, `extractToolText` (the rendering path in `tool-cards.ts`) explicitly falls back to `item.content`:
```typescript
function extractToolText(item: Record<string, unknown>): string | undefined {
if (typeof item.text === "string") return item.text;
if (typeof item.content === "string") return item.content; // ← large payload rendered here
return undefined;
}
```
This means the budget will not trigger for the class of tool-result messages most likely to cause the freezes described in the PR, and sessions with large tool outputs stored in `content` fields will pass right through the 240k guard just as they did before.
The fix is to account for `item.content` in the char estimation by operating on the raw items alongside the normalized ones:
```typescript
function estimateMessageRenderChars(message: unknown): number {
const normalized = normalizeMessage(message);
const raw = message as Record<string, unknown>;
const rawContent = Array.isArray(raw.content) ? (raw.content as Record<string, unknown>[]) : [];
let chars = 0;
for (let i = 0; i < normalized.content.length; i++) {
const item = normalized.content[i];
const rawItem = rawContent[i] as Record<string, unknown> | undefined;
if (typeof item.text === "string") {
chars += item.text.length;
}
// tool_result blocks store output in `content`, not `text`
if (rawItem && typeof rawItem.content === "string") {
chars += rawItem.content.length;
}
if (typeof item.args === "string") {
chars += item.args.length;
} else if (item.args && typeof item.args === "object") {
try { chars += JSON.stringify(item.args).length; } catch { /* ignore */ }
}
}
return Math.max(chars, 1);
}
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "UI: cap chat history rendering by char b..." | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 398e7ead93
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
398e7ea to
049fcad
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 049fcadf18
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
ProjectClownfish pushed a narrow repair to this branch so the original contributor path can stay canonical. Source PR: #56783 |
07afdbc to
9f08eef
Compare
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟡 Client-side DoS via unbounded recursive traversal of message content when estimating chat history size
Description
Vulnerable code: function estimateRawContentChars(value: unknown): number {
if (Array.isArray(value)) {
return value.reduce((total, item) => total + estimateRawContentChars(item), 0);
}
...
if (record.content !== undefined) {
chars += estimateRawContentChars(record.content);
}
return chars;
}RecommendationMake the estimator bounded and cycle-safe, and short-circuit once the budget is exceeded. Suggested approach:
Example: function estimateRawContentCharsSafe(
value: unknown,
opts: { remaining: number; depth: number; maxDepth: number; visited: WeakSet<object> },
): number {
if (opts.remaining <= 0) return 0;
if (typeof value === "string") return Math.min(value.length, opts.remaining);
if (!value || typeof value !== "object") return 0;
if (opts.depth >= opts.maxDepth) return 0;
if (opts.visited.has(value)) return 0;
opts.visited.add(value);
if (Array.isArray(value)) {
let total = 0;
for (const item of value) {
const n = estimateRawContentCharsSafe(item, { ...opts, remaining: opts.remaining - total, depth: opts.depth + 1 });
total += n;
if (total >= opts.remaining) break;
}
return total;
}
const record = value as Record<string, unknown>;
let total = 0;
if (typeof record.text === "string") total += Math.min(record.text.length, opts.remaining - total);
if (record.content !== undefined && total < opts.remaining) {
total += estimateRawContentCharsSafe(record.content, { ...opts, remaining: opts.remaining - total, depth: opts.depth + 1 });
}
return total;
}Then call this from 2. 🟡 Chat UI denial-of-service via unguarded normalizeMessage() calls in history trimming helpers
DescriptionThe chat history rendering path can crash the UI when a malformed/partial message (e.g.,
Vulnerable code: function isHiddenToolMessage(message: unknown, showToolCalls: boolean): boolean {
return !showToolCalls && normalizeMessage(message).role.toLowerCase() === "toolresult";
}RecommendationMake history trimming tolerant of malformed message entries. Options:
Example fix (call-site wrapper): function safeNormalizeMessage(message: unknown) {
try {
if (!message || typeof message !== "object") {
return { role: "unknown", content: [], timestamp: Date.now() };
}
return normalizeMessage(message);
} catch {
return { role: "unknown", content: [], timestamp: Date.now() };
}
}
function isHiddenToolMessage(message: unknown, showToolCalls: boolean): boolean {
const normalized = safeNormalizeMessage(message);
return !showToolCalls && normalized.role.toLowerCase() === "toolresult";
}This prevents a single malformed message in persisted/provider history from crashing chat rendering. Analyzed PR: #56783 at commit Last updated on: 2026-04-28T19:02:38Z |
|
Thanks for the context here. I did a careful shell check against current This PR can close because current main already contains the useful Control UI history-budget fix through the merged replacement in #81771, including the later safeguards requested in review. So I’m closing this as already implemented rather than keeping a duplicate issue open. Review detailsBest possible solution: Keep the current-main implementation from the merged replacement and close this conflicting source branch instead of rebasing it. Do we have a high-confidence way to reproduce the issue? Yes for the historical bug path from source and linked report context, but no current-main failing reproduction remains. Current main now budgets chat history by count and render characters with focused regression coverage. Is this the best way to solve the issue? Yes for current main, not for this branch. The merged replacement is the narrower maintainable solution because it preserves the 100-message cap and bounds estimator traversal, while the source branch still carries obsolete/conflicting behavior. Security review: Security review needs attention: The source branch has a concrete client-side availability concern from unbounded recursive history-size estimation; current main's replacement bounds that traversal.
What I checked:
Likely related people:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 238b0fc76fa7; fix evidence: commit 348ffe60612c, main fix timestamp 2026-05-14T10:36:09Z. |
Summary
Describe the problem and fix in 2–5 bullets:
chat.historypayload shape, server-side truncation, or virtualized rendering architecture.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause / Regression History (if applicable)
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write
N/A. If the cause is unclear, writeUnknown.git blame, prior PR, issue, or refactor if known): issue reports in [Bug]: WebChat UI freezes on large sessions — synchronous markdown rendering of 200 messages blocks main thread #11890 describe freezes with large tool-result-heavy sessions.Regression Test Plan (if applicable)
For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write
N/A.ui/src/ui/views/chat.test.tschat.test.tscover nearby rendering behavior but not history-window budgeting.User-visible / Behavior Changes
Diagram (if applicable)
For UI changes or non-trivial logic flows, include a small ASCII diagram reviewers can scan quickly. Otherwise write
N/A.Security Impact (required)
Yes/No) NoYes/No) NoYes/No) NoYes/No) NoYes/No) NoYes, explain risk + mitigation:Repro + Verification
Environment
Steps
Expected
Actual
Evidence
Attach at least one:
pnpm test -- ui/src/ui/views/chat.test.tspasses (37 tests).pnpm checkpasses.Human Verification (required)
What you personally verified (not just CI), and how:
Review Conversations
If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.
Compatibility / Migration
Yes/No) YesYes/No) NoYes/No) NoRisks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write
None.Made with Cursor