openclaw - 💡(How to fix) Fix webchat shows each user message twice (chat.history dumps all JSONL lines instead of walking the active branch) [1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#72975Fetched 2026-04-28 06:29:12
View on GitHub
Comments
0
Participants
1
Timeline
0
Reactions
0
Author
Participants

Root Cause

Two interacting parts:

  1. rewriteSubmittedPromptTranscript (dist/selection-D2NVpkJL.js:5500, called from line 7118) handles the metadata wrapper by branching the session and re-appending a replacement entry via rewriteTranscriptEntriesInSessionManager (dist/transcript-rewrite-DEfbMkgI.js:45). Because the JSONL is append-only, both the original and the replacement entries persist on disk under the same parentId. This is by design.

  2. readSessionMessages (dist/session-utils.fs-BDLex3kU.js:227, called from chat.history handler in dist/chat-CXQS68Yn.js:2052) reads every line of the transcript JSONL and emits a message per line. It does not consult SessionManager.getBranch() to filter to the active branch, so it returns both the abandoned original and the rewritten replacement.

Result: the cleaned-up replacement is rendered, AND the LLM-facing original (with the Sender (untrusted metadata): block) is also rendered. The user sees both.

Other call sites (e.g. compact-BKvUF_Ui.js:190, chat-CXQS68Yn.js:1292) correctly use SessionManager.open(...).getBranch(). chat.history is the outlier.

Code Example

+ import { SessionManager } from "@mariozechner/pi-coding-agent";
  function readSessionMessages(sessionId, storePath, sessionFile) {
    const filePath = resolveSessionTranscriptCandidates(...).find(p => fs.existsSync(p));
    if (!filePath) return [];
    const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
+   let activeBranchIds = null;
+   try {
+     const branch = SessionManager.open(filePath).getBranch();
+     if (branch?.length) {
+       activeBranchIds = new Set();
+       for (const entry of branch) if (typeof entry?.id === "string") activeBranchIds.add(entry.id);
+     }
+   } catch {}
    ...
    for (const line of lines) {
      ...
      const parsed = JSON.parse(line);
+     if (activeBranchIds && typeof parsed?.id === "string" && !activeBranchIds.has(parsed.id)) continue;
      if (parsed?.message) { ... }
    }
  }
RAW_BUFFERClick to expand / collapse

Symptom

In the webchat (openclaw-control-ui), every user message renders twice in the chat history: once as the LLM-facing copy (with the Sender (untrusted metadata): block prepended), and once as the cleaned transcript copy (without the wrapper). Reloading the page or reconnecting reproduces it 100%.

Version

  • OpenClaw 2026.4.24
  • Container image (openclaw-secure)
  • Chat origin: webchat from openclaw-control-ui v2026.4.24

Root cause

Two interacting parts:

  1. rewriteSubmittedPromptTranscript (dist/selection-D2NVpkJL.js:5500, called from line 7118) handles the metadata wrapper by branching the session and re-appending a replacement entry via rewriteTranscriptEntriesInSessionManager (dist/transcript-rewrite-DEfbMkgI.js:45). Because the JSONL is append-only, both the original and the replacement entries persist on disk under the same parentId. This is by design.

  2. readSessionMessages (dist/session-utils.fs-BDLex3kU.js:227, called from chat.history handler in dist/chat-CXQS68Yn.js:2052) reads every line of the transcript JSONL and emits a message per line. It does not consult SessionManager.getBranch() to filter to the active branch, so it returns both the abandoned original and the rewritten replacement.

Result: the cleaned-up replacement is rendered, AND the LLM-facing original (with the Sender (untrusted metadata): block) is also rendered. The user sees both.

Other call sites (e.g. compact-BKvUF_Ui.js:190, chat-CXQS68Yn.js:1292) correctly use SessionManager.open(...).getBranch(). chat.history is the outlier.

Repro

  1. Send any text message via webchat.
  2. Wait for the agent to reply.
  3. Inspect ~/.openclaw/agents/main/sessions/<id>.jsonl — your message appears twice with the same parentId, ~20-30 s apart, one with the metadata wrapper and one without.
  4. Reload the webchat — both render.

On a session that's been used for a while, the disk-vs-rendered ratio is roughly 2:1 for user messages. (In our session: 102 raw message entries → 52 active-branch entries.)

Suggested fix

Filter readSessionMessages by the active branch:

+ import { SessionManager } from "@mariozechner/pi-coding-agent";
  function readSessionMessages(sessionId, storePath, sessionFile) {
    const filePath = resolveSessionTranscriptCandidates(...).find(p => fs.existsSync(p));
    if (!filePath) return [];
    const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
+   let activeBranchIds = null;
+   try {
+     const branch = SessionManager.open(filePath).getBranch();
+     if (branch?.length) {
+       activeBranchIds = new Set();
+       for (const entry of branch) if (typeof entry?.id === "string") activeBranchIds.add(entry.id);
+     }
+   } catch {}
    ...
    for (const line of lines) {
      ...
      const parsed = JSON.parse(line);
+     if (activeBranchIds && typeof parsed?.id === "string" && !activeBranchIds.has(parsed.id)) continue;
      if (parsed?.message) { ... }
    }
  }

Verified locally — eliminates the duplicate rendering with no other observable side effects.

extent analysis

TL;DR

Filtering readSessionMessages by the active branch using SessionManager.getBranch() should resolve the duplicate message rendering issue.

Guidance

  • The suggested fix involves modifying the readSessionMessages function to filter out messages not in the active branch, which can be achieved by using SessionManager.open(filePath).getBranch() to get the active branch and then checking if each message's ID is in that branch.
  • To verify the fix, send a text message via webchat, wait for the agent to reply, and then inspect the rendered chat history to ensure that only one copy of the user's message is displayed.
  • The provided code snippet demonstrates how to implement this filtering, and it has been verified to work locally without introducing other observable side effects.
  • It's essential to ensure that the SessionManager import and the getBranch() method call are correctly integrated into the existing codebase.

Example

The provided code snippet already includes an example of how to filter readSessionMessages by the active branch:

+ import { SessionManager } from "@mariozechner/pi-coding-agent";
  function readSessionMessages(sessionId, storePath, sessionFile) {
    ...
+   let activeBranchIds = null;
+   try {
+     const branch = SessionManager.open(filePath).getBranch();
+     if (branch?.length) {
+       activeBranchIds = new Set();
+       for (const entry of branch) if (typeof entry?.id === "string") activeBranchIds.add(entry.id);
+     }
+   } catch {}
    ...
    for (const line of lines) {
      ...
+     if (activeBranchIds && typeof parsed?.id === "string" && !activeBranchIds.has(parsed.id)) continue;
      if (parsed?.message) { ... }
    }
  }

Notes

The fix assumes that the SessionManager and its methods

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix webchat shows each user message twice (chat.history dumps all JSONL lines instead of walking the active branch) [1 participants]