Skip to content

UI: cap Control UI chat history rendering by char budget#56783

Closed
tonga54 wants to merge 2 commits into
openclaw:mainfrom
tonga54:fix/issue-11890-chat-history-budget
Closed

UI: cap Control UI chat history rendering by char budget#56783
tonga54 wants to merge 2 commits into
openclaw:mainfrom
tonga54:fix/issue-11890-chat-history-budget

Conversation

@tonga54

@tonga54 tonga54 commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: WebChat can still freeze on very large sessions because the UI attempts to render up to 200 history messages even when those messages contain massive tool-output payloads.
  • Why it matters: users can hit long main-thread stalls and lose interactivity in the Control UI on long-running sessions.
  • What changed: history selection now applies both a message-count cap (200) and a total character budget (240k) before rendering; the history notice now reports the actual visible/hidden counts.
  • What did NOT change (scope boundary): this PR does not change gateway chat.history payload shape, server-side truncation, or virtualized rendering architecture.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

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, write Unknown.

  • Root cause: history rendering was bounded only by message count, not by total payload size, so a small number of very large messages could still trigger expensive synchronous render work.
  • Missing detection / guardrail: no UI-level character-budget gate in the history windowing logic.
  • Prior context (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.
  • Why this regressed now: heavy tool output and long-lived sessions increased per-message payloads beyond what a count-only cap can safely handle.
  • If unknown, what was ruled out: N/A.

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.

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: ui/src/ui/views/chat.test.ts
  • Scenario the test should lock in: (1) count cap still works, (2) char-budget cap hides older messages even when message count is below 200.
  • Why this is the smallest reliable guardrail: the regression is pure UI history-window selection logic and is deterministic under unit tests.
  • Existing test that already covers this (if any): existing context-notice tests in chat.test.ts cover nearby rendering behavior but not history-window budgeting.
  • If no new test is added, why not: N/A.

User-visible / Behavior Changes

  • In large sessions, Control UI may display fewer than 200 messages when message payloads are very large.
  • The history notice now reflects actual visible message count (instead of always saying "last 200").

Diagram (if applicable)

For UI changes or non-trivial logic flows, include a small ASCII diagram reviewers can scan quickly. Otherwise write N/A.

Before:
[load chat] -> [show last 200 messages] -> [can include huge payload total] -> [main-thread stall]

After:
[load chat] -> [apply count cap + char budget] -> [bounded payload render] -> [faster, responsive UI]

Security Impact (required)

  • New permissions/capabilities? (Yes/No) No
  • Secrets/tokens handling changed? (Yes/No) No
  • New/changed network calls? (Yes/No) No
  • Command/tool execution surface changed? (Yes/No) No
  • Data access scope changed? (Yes/No) No
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: macOS (dev)
  • Runtime/container: Node 24 / pnpm workspace
  • Model/provider: N/A (UI rendering logic)
  • Integration/channel (if any): Control UI WebChat
  • Relevant config (redacted): default chat settings

Steps

  1. Open a long-running session with many large tool outputs.
  2. Render chat history in Control UI.
  3. Confirm UI remains responsive and shows a history truncation notice when older messages are hidden.

Expected

  • Chat view stays responsive; rendered history payload is bounded.

Actual

  • UI now limits history by both message count and char budget before rendering.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

pnpm test -- ui/src/ui/views/chat.test.ts passes (37 tests).
pnpm check passes.

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: count-capped history still shows notice; large-message history now char-budget-capped with correct visible/hidden notice.
  • Edge cases checked: history with fewer than count cap can still be trimmed when payloads are huge; at least one latest message still renders.
  • What you did not verify: full manual browser perf profiling across multiple OS/browser combinations.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

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

  • Backward compatible? (Yes/No) Yes
  • Config/env changes? (Yes/No) No
  • Migration needed? (Yes/No) No
  • If yes, exact upgrade steps:

Risks and Mitigations

List only real risks for this PR. Add/remove entries as needed. If none, write None.

  • Risk: very large older messages may be hidden sooner than before in a single view.
    • Mitigation: explicit system notice shows hidden count; newest messages remain prioritized.

Made with Cursor

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ui/src/ui/views/chat.ts Outdated
@greptile-apps

greptile-apps Bot commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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 (count > 0 before the budget check) is a sensible safety net. The history-notice wording is also correctly updated to reflect the actual visible count.

Issues found:

  • P1 — Char estimation silently misses tool-result payloads (the primary large-payload case): estimateMessageRenderChars inspects normalized.text and normalized.args only. Tool-result content blocks in the Anthropic API format ({ type: \"tool_result\", tool_use_id: \"...\", content: \"large string\" }) populate item.content, not item.text. normalizeMessage does not map item.content to any field in MessageContentItem, so those items yield an estimated cost of 1 char. The render path (extractToolText in tool-cards.ts) does read item.content, so these messages can be very large in the DOM even though the budget never fires for them. The two new unit tests both use top-level string content (mapped through the typeof m.content === \"string\" branch → text), so they do not exercise this gap. A session where freezes are caused by large tool outputs stored in item.content would behave identically before and after this PR.

Confidence Score: 4/5

Safe to merge for correctness, but the char-budget fix may not protect against the specific freeze scenario (large tool-result content payloads) the PR is intended to solve.

There is one P1 finding: estimateMessageRenderChars does not count tool-result content stored in item.content, which is the most common shape for large Anthropic API tool outputs and the stated root cause of the original freezes. The implementation is otherwise correct and backward-compatible, so this is not a regression — the UI won't get worse — but the primary performance fix may be ineffective for the targeted class of messages. The new tests pass because they use a different message shape (top-level string content) that does go through the counted path.

ui/src/ui/views/chat.tsestimateMessageRenderChars function (lines 1410–1427)

Important Files Changed

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

Comment thread ui/src/ui/views/chat.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ui/src/ui/views/chat.ts Outdated
@tonga54
tonga54 force-pushed the fix/issue-11890-chat-history-budget branch from 398e7ea to 049fcad Compare March 29, 2026 15:29

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ui/src/ui/views/chat.ts Outdated
@vincentkoc

Copy link
Copy Markdown
Member

ProjectClownfish pushed a narrow repair to this branch so the original contributor path can stay canonical.

Source PR: #56783
Validation: pnpm -s vitest run ui/src/ui/chat/build-chat-items.test.ts ui/src/ui/controllers/chat.test.ts ui/src/ui/views/chat.test.ts; pnpm check:changed
Contributor credit is preserved in the branch history and PR context.

@vincentkoc
vincentkoc force-pushed the fix/issue-11890-chat-history-budget branch from 07afdbc to 9f08eef Compare April 28, 2026 19:00
@aisle-research-bot

aisle-research-bot Bot commented Apr 28, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Client-side DoS via unbounded recursive traversal of message content when estimating chat history size
2 🟡 Medium Chat UI denial-of-service via unguarded normalizeMessage() calls in history trimming helpers
1. 🟡 Client-side DoS via unbounded recursive traversal of message content when estimating chat history size
Property Value
Severity Medium
CWE CWE-674
Location ui/src/ui/chat/build-chat-items.ts:154-171

Description

buildChatItems() now calls resolveHistoryStartIndex() which estimates the rendered size of each message to enforce a char budget. This estimation walks raw message payloads recursively via estimateRawContentChars().

  • estimateRawContentChars() recursively traverses arrays and record.content with no max depth / node limit and no cycle detection.
  • A deeply nested content structure (e.g., nested tool_result blocks) can cause stack overflow or long synchronous traversal on the UI thread, freezing the chat UI.
  • If a cyclic object graph ever enters messages (e.g., via in-memory composition, plugin/tool code, or non-JSON sources), recursion becomes infinite.

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;
}

Recommendation

Make the estimator bounded and cycle-safe, and short-circuit once the budget is exceeded.

Suggested approach:

  • Track visited objects in a WeakSet<object> to prevent cycles.
  • Add a maxDepth and/or maxNodes cap.
  • Add an optional remainingBudget parameter to stop walking once remainingBudget <= 0.

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 estimateMessageRenderChars() with remaining set to (CHAT_HISTORY_RENDER_CHAR_BUDGET - charsSoFar) (or a per-message cap) so the walk cannot dominate the UI thread.

2. 🟡 Chat UI denial-of-service via unguarded normalizeMessage() calls in history trimming helpers
Property Value
Severity Medium
CWE CWE-703
Location ui/src/ui/chat/build-chat-items.ts:150-152

Description

The chat history rendering path can crash the UI when a malformed/partial message (e.g., null, undefined, or a non-object primitive) is present in props.messages.

  • normalizeMessage(message) assumes message is an object (const m = message as Record<string, unknown>; and then accesses m.role, m.content, etc.). If message is null/undefined, this throws a TypeError.
  • This change introduces additional pre-render passes over history (resolveHistoryStartIndex, countVisibleHistoryMessages) that call normalizeMessage() via isHiddenToolMessage() and estimateMessageRenderChars().
  • As a result, a single bad persisted/provider message can take down the entire chat UI earlier (before the main render loop), constituting an availability/DoS issue.

Vulnerable code:

function isHiddenToolMessage(message: unknown, showToolCalls: boolean): boolean {
  return !showToolCalls && normalizeMessage(message).role.toLowerCase() === "toolresult";
}

Recommendation

Make history trimming tolerant of malformed message entries.

Options:

  1. Harden normalizeMessage to safely handle null/non-objects by treating them as an unknown message with empty content.
  2. Or guard at call sites (recommended here since these helpers are used in tight loops): use a small safe wrapper with try/catch and/or an object check.

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 9f08eef

Last updated on: 2026-04-28T19:02:38Z

@clawsweeper

clawsweeper Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I did a careful shell check against current main, and this is already implemented.

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 details

Best 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.

  • [medium] Unbounded recursive history traversal — ui/src/ui/chat/build-chat-items.ts:154
    estimateRawContentChars on the source branch descends through arrays and nested content without cycle, depth, node, or remaining-budget limits, so malformed history content can monopolize the UI thread or overflow the stack during pre-render trimming.
    Confidence: 0.82

What I checked:

Likely related people:

  • BunsDev: Authored and merged the current-main replacement that includes the bounded chat-history budget, and recently introduced the current 100-message Control UI render cap. (role: recent area contributor and merger; confidence: high; commits: 348ffe60612c, d9692555ee0f, 5ae385b2f0f1; files: ui/src/ui/chat/build-chat-items.ts, ui/src/ui/chat/history-limits.ts, ui/src/ui/controllers/chat.ts)
  • vincentkoc: Pushed the narrow repair commit to this source branch and has recent current-main work in WebChat reload/final-state reconciliation adjacent to the affected path. (role: adjacent area contributor; confidence: medium; commits: 9f08eefa67a6, cff991c88d04, 02908db62b30; files: ui/src/ui/chat/build-chat-items.ts, ui/src/ui/controllers/chat.ts)
  • steipete: Recent history shows chat item builder test extraction, role-normalization import work, and related chat display maintenance around this UI history surface. (role: adjacent area contributor; confidence: medium; commits: 68954f9c6ccb, f62a054ef1ac, f739edcf4c7e; files: ui/src/ui/chat/build-chat-items.ts, ui/src/ui/chat/history-limits.ts, src/gateway/chat-display-projection.ts)

Codex review notes: model gpt-5.5, reasoning high; reviewed against 238b0fc76fa7; fix evidence: commit 348ffe60612c, main fix timestamp 2026-05-14T10:36:09Z.

@vincentkoc vincentkoc added clawsweeper Tracked by ClawSweeper automation and removed clownfish:merge-ready labels Apr 28, 2026
@clawsweeper clawsweeper Bot closed this May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui clawsweeper Tracked by ClawSweeper automation size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: WebChat UI freezes on large sessions — synchronous markdown rendering of 200 messages blocks main thread

3 participants