OpenClaw TUI Keeps Repeating Itself? A Temporary Patch for Duplicate Thinking and Replies
The short version
If OpenClaw TUI shows the same thinking block twice and repeats the final answer, do not immediately delete every session or rebuild every model provider. In many OpenAI-compatible setups, the real issue is a streaming compatibility edge case: the upstream stream emits normal
deltachunks and then an extra fullmessagetail. Some OpenClaw paths may aggregate both, so the TUI and the persisted session both end up with duplicated content.The practical temporary fix is to back up the local OpenClaw installation, add a conservative dedupe guard in the OpenClaw aggregation layer, restart the gateway, and verify with a unique marker that both the visible reply and the session file contain only one copy.
This is a privacy-safe incident note. All endpoints, model names, tokens, internal addresses, and session identifiers are replaced by placeholders. Use your own installation path and model identifiers when applying the workaround.
Figure 1: This article is about duplicated assistant content in OpenClaw TUI and session files, not merely a terminal repaint artifact.
1. Why this failure is easy to misdiagnose
An OpenClaw agent turn crosses several layers: the local TUI, the gateway, the embedded agent runner, the model provider adapter, an OpenAI-compatible gateway, the upstream model, the session transcript, tool-call events, thinking or reasoning fields, final visible text, and sometimes a delivery channel. A problem in any one layer may look like a bad answer.
The duplicate-output failure is especially misleading because it looks clean. It is not garbled text. It is not an empty answer. It is a very orderly repeat. A marker such as PING_ONCE becomes PING_ONCEPING_ONCE. A thinking paragraph appears twice. A final answer paragraph appears twice. In tool-heavy turns, streamed tool-call arguments may also look like two JSON fragments were concatenated.
Common but incomplete explanations include:
- The session history is too old and the model copied earlier context.
- The same model is configured twice and OpenClaw called both.
- The TUI rendered one stored message twice.
- A scheduled job or background channel reused the same agent state.
- The model itself decided to repeat the answer.
Any of those can happen, but they should be tested instead of assumed. The useful debugging path is layered: TUI, session file, direct non-streaming request, direct streaming request, OpenClaw raw stream, and provider library behavior.
2. Symptoms that match this issue
The strongest signal is that the duplicate content is persisted in the session file. If the assistant message stored in the .jsonl transcript already contains duplicated text, the TUI is not the primary cause. A second signal is exact repetition. The content is often not randomly verbose; it is two identical halves:
OC_TEST_ONCEOC_TEST_ONCE
The same can happen to thinking content:
The user asks ... I should answer exactly once.
The user asks ... I should answer exactly once.
A third signal is that direct non-streaming calls to the same OpenAI-compatible endpoint may be fine, while the streaming path duplicates. That narrows the problem from “the model always repeats” to “the stream parser or aggregator is not compatible with this event shape.” A fourth signal is that the model list looks normal. openclaw models list may show one primary model and one fallback model, but model catalog cleanliness does not prove that streaming event handling is clean.
3. Collect evidence before editing anything
Start with a unique marker:
MARKER="OC_DEDUPE_TEST_$(date +%H%M%S)"
openclaw agent \
--agent main \
--session-key "agent:main:dedupe-test-${MARKER}" \
--message "Reply exactly once with: ${MARKER}" \
--timeout 120 \
--json
If the reply is duplicated, inspect the session file returned in the JSON metadata:
SESSION_FILE="<sessionFile from the JSON output>"
grep -n "OC_DEDUPE_TEST" "$SESSION_FILE"
If the stored assistant message is already duplicated, the issue happened before TUI rendering.
Then compare non-streaming and streaming calls to the same OpenAI-compatible endpoint. Replace <BASE_URL>, <API_KEY>, and <MODEL_ID> with your own values.
curl -sS "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "<MODEL_ID>",
"messages": [{"role":"user","content":"Reply exactly once with: PING_ONCE"}],
"stream": false
}' | jq '.choices[0].message.content'
Now test streaming:
curl -N "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "<MODEL_ID>",
"messages": [{"role":"user","content":"Reply exactly once with: STREAM_ONCE"}],
"stream": true
}'
A normal streaming client accumulates choices[].delta and stops after the finish reason. If your compatible gateway sends an additional full message.content tail after the delta stream, a naive or overly trusting aggregator may append both.
Figure 2: A standard streaming client builds the final answer from delta chunks.
Figure 3: If a compatible endpoint sends both deltas and a full message tail, duplicate text may appear.
4. Root cause
The root cause is best described as a compatibility boundary failure: an OpenAI-compatible gateway emits a stream shape that OpenClaw’s current aggregation path does not fully tolerate, and the aggregation layer lacks a conservative duplicate-folding guard.
Non-streaming requests can succeed. A direct call through the base provider library can also succeed. But when the full OpenClaw agent runner is involved, with system prompt, tools, session writing, message update events, and final message-end events, duplicate text_delta and duplicate thinking events may reach the assistant message state.
A raw stream trace often looks like this:
thinking_delta: "The user is ..."
text_delta: "OC_TEST_ONCE"
thinking_delta: "The user is ..." # repeated full thinking
text_delta: "OC_TEST_ONCE" # repeated text
message_end: rawText="OC_TEST_ONCEOC_TEST_ONCE"
That is why clearing old sessions or deleting stale model cache files does not permanently fix the issue. The next streamed agent turn can reproduce it again.
5. Temporary patch: back up first, then dedupe at aggregation time
The workaround below is intended for a local installation, especially macOS/Homebrew or global npm-style installs. Adjust paths for your own installation.
5.1 Locate files and create backups
OPENCLAW_ROOT="$(npm root -g 2>/dev/null)/openclaw"
if [ ! -d "$OPENCLAW_ROOT" ]; then
OPENCLAW_ROOT="/opt/homebrew/lib/node_modules/openclaw"
fi
SELECTION_FILE="$(find "$OPENCLAW_ROOT/dist" -maxdepth 1 -name 'selection-*.js' | head -1)"
PROVIDER_FILE="$OPENCLAW_ROOT/node_modules/@earendil-works/pi-ai/dist/providers/openai-completions.js"
BACKUP_DIR="$HOME/.openclaw/backups/dedupe-patch-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp "$SELECTION_FILE" "$BACKUP_DIR/$(basename "$SELECTION_FILE")"
cp "$PROVIDER_FILE" "$BACKUP_DIR/openai-completions.js"
shasum -a 256 "$BACKUP_DIR"/* > "$BACKUP_DIR/SHA256SUMS"
echo "Backup: $BACKUP_DIR"
Do not skip this step. You are editing installed runtime files, and a package upgrade may overwrite the patch later.
5.2 Add a conservative dedupe guard
This guard does three things: if a text_delta equals the already accumulated text, ignore it; before an assistant message is persisted, fold exact doubled text or thinking content into one copy; if the content array contains identical text or thinking blocks, keep one.
python3 - <<'PATCH_PY'
from pathlib import Path
import os
root = Path(os.environ.get('OPENCLAW_ROOT', '/opt/homebrew/lib/node_modules/openclaw'))
selection_files = list((root / 'dist').glob('selection-*.js'))
if not selection_files:
raise SystemExit('selection-*.js not found')
path = selection_files[0]
s = path.read_text()
old = 'if (evtType === "text_delta") return delta;'
new = '''if (evtType === "text_delta") {
\t\t\tif (delta && accumulatedText && delta === accumulatedText) return "";
\t\t\treturn delta;
\t\t}'''
if old in s and new not in s:
s = s.replace(old, new, 1)
anchor = 'function resolveSilentReplyFallbackText(params) {'
helper = '''function collapseExactDoubledString(value) {
\t\tif (typeof value !== "string" || value.length < 8 || value.length % 2 !== 0) return value;
\t\tconst half = value.length / 2;
\t\tconst first = value.slice(0, half);
\t\treturn first === value.slice(half) ? first : value;
\t}
\tfunction normalizeDedupeText(value) {
\t\treturn typeof value === "string" ? value.trim().replace(/\\s+/g, " ") : "";
\t}
\tfunction dedupeOpenAiCompatAssistantMessage(message) {
\t\tif (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
\t\tconst seenThinking = new Set();
\t\tconst seenText = new Set();
\t\tconst next = [];
\t\tfor (const block of message.content) {
\t\t\tif (!block || typeof block !== "object") { next.push(block); continue; }
\t\t\tif (block.type === "text" && typeof block.text === "string") {
\t\t\t\tblock.text = collapseExactDoubledString(block.text);
\t\t\t\tconst key = normalizeDedupeText(block.text);
\t\t\t\tif (key && seenText.has(key)) continue;
\t\t\t\tif (key) seenText.add(key);
\t\t\t} else if (block.type === "thinking" && typeof block.thinking === "string") {
\t\t\t\tblock.thinking = collapseExactDoubledString(block.thinking);
\t\t\t\tconst key = `${block.thinkingSignature ?? ""}:${normalizeDedupeText(block.thinking)}`;
\t\t\t\tif (key !== ":" && seenThinking.has(key)) continue;
\t\t\t\tif (key !== ":") seenThinking.add(key);
\t\t\t} else if (block.type === "toolCall" && typeof block.partialArgs === "string") {
\t\t\t\tblock.partialArgs = collapseExactDoubledString(block.partialArgs);
\t\t\t}
\t\t\tnext.push(block);
\t\t}
\t\tmessage.content = next;
\t}
\t'''
if helper.strip() not in s:
if anchor not in s:
raise SystemExit('helper anchor not found')
s = s.replace(anchor, helper + anchor, 1)
old_update = 'if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage$1(msg)) return;\n\tctx.noteLastAssistant(msg);'
new_update = 'if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage$1(msg)) return;\n\tdedupeOpenAiCompatAssistantMessage(msg);\n\tctx.noteLastAssistant(msg);'
if old_update in s and new_update not in s:
s = s.replace(old_update, new_update, 1)
old_end = 'const assistantMessage = msg;\n\tconst assistantPhase = resolveAssistantMessagePhase(assistantMessage);'
new_end = 'const assistantMessage = msg;\n\tdedupeOpenAiCompatAssistantMessage(assistantMessage);\n\tconst assistantPhase = resolveAssistantMessagePhase(assistantMessage);'
if old_end in s and new_end not in s:
s = s.replace(old_end, new_end, 1)
path.write_text(s)
print(path)
PATCH_PY
The patch intentionally avoids semantic rewriting. It only collapses exact duplicates.
5.3 Optional provider-side guard
If your compatible gateway sends chunks after a finish reason, you can add a provider-side guard too:
python3 - <<'PATCH_PY'
from pathlib import Path
import os
root = Path(os.environ.get('OPENCLAW_ROOT', '/opt/homebrew/lib/node_modules/openclaw'))
path = root / 'node_modules/@earendil-works/pi-ai/dist/providers/openai-completions.js'
s = path.read_text()
old = '''const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice)
continue;'''
new = '''const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice)
continue;
if (hasFinishReason)
continue;'''
if old in s and new not in s:
s = s.replace(old, new, 1)
path.write_text(s)
print(path)
PATCH_PY
This may not be required in every environment. The aggregation-layer guard is the more important part.
5.4 Complete executable auto-patch script
If you do not want to copy the commands section by section, use the complete script below. It locates the OpenClaw installation, backs up target files, checks patch anchors, writes the patch, and supports both --dry-run and --restart.
Recommended flow: dry-run first, then patch, then restart and verify.
mkdir -p ~/.openclaw/patches
nano ~/.openclaw/patches/patch_openclaw_duplicate_stream.py
# Paste the complete script below and save it
python3 ~/.openclaw/patches/patch_openclaw_duplicate_stream.py --dry-run
python3 ~/.openclaw/patches/patch_openclaw_duplicate_stream.py --restart
If OpenClaw is installed somewhere else, pass the package root explicitly:
python3 ~/.openclaw/patches/patch_openclaw_duplicate_stream.py --root /path/to/openclaw --restart
Complete script:
#!/usr/bin/env python3
# Patch local OpenClaw runtime files to suppress exact duplicate thinking/text
# content from OpenAI-compatible streaming responses.
#
# Usage:
# python3 patch_openclaw_duplicate_stream.py
# python3 patch_openclaw_duplicate_stream.py --dry-run
# python3 patch_openclaw_duplicate_stream.py --root /opt/homebrew/lib/node_modules/openclaw
# python3 patch_openclaw_duplicate_stream.py --restart
from __future__ import annotations
import argparse
import datetime as _dt
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Iterable, List, Optional, Tuple
SELECTION_TEXT_DELTA_OLD = 'if (evtType === "text_delta") return delta;'
SELECTION_TEXT_DELTA_NEW = '''if (evtType === "text_delta") {
\t\t\tif (delta && accumulatedText && delta === accumulatedText) return "";
\t\t\treturn delta;
\t\t}'''
HELPER_ANCHOR = 'function resolveSilentReplyFallbackText(params) {'
HELPER_CODE = '''function collapseExactDoubledString(value) {
\t\tif (typeof value !== "string" || value.length < 8 || value.length % 2 !== 0) return value;
\t\tconst half = value.length / 2;
\t\tconst first = value.slice(0, half);
\t\treturn first === value.slice(half) ? first : value;
\t}
\tfunction normalizeDedupeText(value) {
\t\treturn typeof value === "string" ? value.trim().replace(/\\s+/g, " ") : "";
\t}
\tfunction dedupeOpenAiCompatAssistantMessage(message) {
\t\tif (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
\t\tconst seenThinking = new Set();
\t\tconst seenText = new Set();
\t\tconst next = [];
\t\tfor (const block of message.content) {
\t\t\tif (!block || typeof block !== "object") { next.push(block); continue; }
\t\t\tif (block.type === "text" && typeof block.text === "string") {
\t\t\t\tblock.text = collapseExactDoubledString(block.text);
\t\t\t\tconst key = normalizeDedupeText(block.text);
\t\t\t\tif (key && seenText.has(key)) continue;
\t\t\t\tif (key) seenText.add(key);
\t\t\t} else if (block.type === "thinking" && typeof block.thinking === "string") {
\t\t\t\tblock.thinking = collapseExactDoubledString(block.thinking);
\t\t\t\tconst key = `${block.thinkingSignature ?? ""}:${normalizeDedupeText(block.thinking)}`;
\t\t\t\tif (key !== ":" && seenThinking.has(key)) continue;
\t\t\t\tif (key !== ":") seenThinking.add(key);
\t\t\t} else if (block.type === "toolCall" && typeof block.partialArgs === "string") {
\t\t\t\tblock.partialArgs = collapseExactDoubledString(block.partialArgs);
\t\t\t}
\t\t\tnext.push(block);
\t\t}
\t\tmessage.content = next;
\t}
\t'''
UPDATE_LAST_OLD = 'if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage$1(msg)) return;\n\tctx.noteLastAssistant(msg);'
UPDATE_LAST_NEW = 'if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage$1(msg)) return;\n\tdedupeOpenAiCompatAssistantMessage(msg);\n\tctx.noteLastAssistant(msg);'
ASSISTANT_END_OLD = 'const assistantMessage = msg;\n\tconst assistantPhase = resolveAssistantMessagePhase(assistantMessage);'
ASSISTANT_END_NEW = 'const assistantMessage = msg;\n\tdedupeOpenAiCompatAssistantMessage(assistantMessage);\n\tconst assistantPhase = resolveAssistantMessagePhase(assistantMessage);'
PROVIDER_OLD = '''const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice)
continue;'''
PROVIDER_NEW = '''const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice)
continue;
if (hasFinishReason)
continue;'''
class PatchError(RuntimeError):
pass
def log(message: str) -> None:
print(f"[openclaw-patch] {message}")
def run_text(cmd: List[str]) -> Optional[str]:
try:
return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip()
except Exception:
return None
def candidate_roots(cli_root: Optional[str]) -> Iterable[Path]:
if cli_root:
yield Path(cli_root).expanduser()
return
env_root = os.environ.get("OPENCLAW_ROOT")
if env_root:
yield Path(env_root).expanduser()
npm_root = run_text(["npm", "root", "-g"])
if npm_root:
yield Path(npm_root) / "openclaw"
yield Path("/opt/homebrew/lib/node_modules/openclaw")
yield Path("/usr/local/lib/node_modules/openclaw")
def find_root(cli_root: Optional[str]) -> Path:
for root in candidate_roots(cli_root):
if (root / "dist").is_dir():
return root
raise PatchError("OpenClaw root not found. Pass --root /path/to/openclaw or set OPENCLAW_ROOT.")
def find_selection_file(root: Path) -> Path:
files = sorted((root / "dist").glob("selection-*.js"), key=lambda p: p.stat().st_mtime, reverse=True)
if not files:
raise PatchError(f"selection-*.js not found under {root / 'dist'}")
return files[0]
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def backup_files(paths: List[Path], dry_run: bool) -> Optional[Path]:
backup_root = Path.home() / ".openclaw" / "backups"
stamp = _dt.datetime.now().strftime("dedupe-patch-%Y%m%d-%H%M%S")
backup_dir = backup_root / stamp
if dry_run:
log(f"dry run: would create backup at {backup_dir}")
return None
backup_dir.mkdir(parents=True, exist_ok=False)
manifest = []
for path in paths:
if not path.exists():
continue
target = backup_dir / path.name
shutil.copy2(path, target)
manifest.append(f"{sha256(target)} {target.name}\n")
(backup_dir / "SHA256SUMS").write_text("".join(manifest), encoding="utf-8")
log(f"backup created: {backup_dir}")
return backup_dir
def replace_or_confirm(text: str, old: str, new: str, label: str, required: bool = True) -> Tuple[str, bool]:
if new in text:
log(f"already patched: {label}")
return text, False
if old not in text:
message = f"pattern not found: {label}"
if required:
raise PatchError(message)
log(f"warning: {message}")
return text, False
log(f"patching: {label}")
return text.replace(old, new, 1), True
def patch_selection(path: Path) -> bool:
text = path.read_text(encoding="utf-8")
changed_any = False
text, changed = replace_or_confirm(text, SELECTION_TEXT_DELTA_OLD, SELECTION_TEXT_DELTA_NEW, "text_delta duplicate guard")
changed_any = changed_any or changed
if HELPER_CODE.strip() in text:
log("already patched: dedupe helper")
else:
if HELPER_ANCHOR not in text:
raise PatchError("helper anchor not found in selection file")
log("patching: dedupe helper")
text = text.replace(HELPER_ANCHOR, HELPER_CODE + HELPER_ANCHOR, 1)
changed_any = True
text, changed = replace_or_confirm(text, UPDATE_LAST_OLD, UPDATE_LAST_NEW, "dedupe before noteLastAssistant")
changed_any = changed_any or changed
text, changed = replace_or_confirm(text, ASSISTANT_END_OLD, ASSISTANT_END_NEW, "dedupe before assistant phase resolution")
changed_any = changed_any or changed
path.write_text(text, encoding="utf-8")
return changed_any
def patch_provider(path: Path) -> bool:
if not path.exists():
log(f"warning: provider file not found: {path}")
return False
text = path.read_text(encoding="utf-8")
text2, changed = replace_or_confirm(text, PROVIDER_OLD, PROVIDER_NEW, "provider finish_reason guard", required=False)
if changed:
path.write_text(text2, encoding="utf-8")
return changed
def restart_gateway() -> None:
uid = os.getuid()
label = f"gui/{uid}/ai.openclaw.gateway"
try:
subprocess.check_call(["launchctl", "kickstart", "-k", label])
log(f"restarted LaunchAgent: {label}")
except Exception as exc:
log(f"restart failed, restart OpenClaw manually: {exc}")
def main() -> int:
parser = argparse.ArgumentParser(description="Patch OpenClaw duplicate streaming output locally.")
parser.add_argument("--root", help="OpenClaw package root, for example /opt/homebrew/lib/node_modules/openclaw")
parser.add_argument("--dry-run", action="store_true", help="Check paths and patch anchors without writing files")
parser.add_argument("--restart", action="store_true", help="Restart ai.openclaw.gateway with launchctl after patching")
args = parser.parse_args()
try:
root = find_root(args.root)
selection = find_selection_file(root)
provider = root / "node_modules/@earendil-works/pi-ai/dist/providers/openai-completions.js"
log(f"OpenClaw root: {root}")
log(f"selection file: {selection}")
log(f"provider file: {provider}")
if args.dry_run:
patch_selection_text = selection.read_text(encoding="utf-8")
for label, old, new in [
("text_delta duplicate guard", SELECTION_TEXT_DELTA_OLD, SELECTION_TEXT_DELTA_NEW),
("dedupe before noteLastAssistant", UPDATE_LAST_OLD, UPDATE_LAST_NEW),
("dedupe before assistant phase resolution", ASSISTANT_END_OLD, ASSISTANT_END_NEW),
]:
if new in patch_selection_text:
log(f"dry run: already patched: {label}")
elif old in patch_selection_text:
log(f"dry run: patchable: {label}")
else:
raise PatchError(f"dry run failed, pattern not found: {label}")
if HELPER_CODE.strip() not in patch_selection_text and HELPER_ANCHOR not in patch_selection_text:
raise PatchError("dry run failed, helper anchor not found")
log("dry run passed")
return 0
backup_files([selection, provider], dry_run=False)
changed_selection = patch_selection(selection)
changed_provider = patch_provider(provider)
if changed_selection or changed_provider:
log("patch complete")
else:
log("no changes needed; files already looked patched")
if args.restart:
restart_gateway()
else:
log("restart OpenClaw gateway/TUI before verifying")
return 0
except PatchError as exc:
print(f"[openclaw-patch] ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
The exit code is automation-friendly: 0 means the check or patch succeeded; 2 means the script could not find the install root, target files, or expected code anchors. If you get 2, do not force-edit the runtime. Check whether your OpenClaw version has changed first.
Figure 4: Back up, fold duplicates, restart, and verify. That is the minimum safe loop for this kind of runtime patch.
6. Restart and verify
Restart the gateway. On a macOS LaunchAgent setup, the command is commonly:
launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway
For systemd, containers, or a manually launched gateway, use the equivalent restart method. Then run another marker test:
MARKER="OC_FIX_OK_$(date +%H%M%S)"
openclaw agent \
--agent main \
--session-key "agent:main:dedupe-test-${MARKER}" \
--message "Reply exactly once with: ${MARKER}" \
--timeout 120 \
--json
The expected visible reply is a single copy:
OC_FIX_OK_101820
Now inspect the session file. The assistant content should contain one thinking block and one text block, not duplicated halves:
[
{"type":"thinking","thinking":"..."},
{"type":"text","text":"OC_FIX_OK_101820"}
]
If the session still contains OC_FIX_OK_101820OC_FIX_OK_101820, the patch probably did not hit the runtime file actually loaded by the gateway. Check the running process and installation path before changing anything else.
7. Rollback
Restore from your backup directory:
cp "$BACKUP_DIR/selection-*.js" "$SELECTION_FILE"
cp "$BACKUP_DIR/openai-completions.js" "$PROVIDER_FILE"
launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway
After rollback, run the marker test again. Remember that upgrading or reinstalling OpenClaw may remove the local patch. That is expected. Re-test first; only reapply the workaround if the new version still reproduces the issue.
8. Q&A
Does clearing sessions fix it?
No. Clearing sessions helps rule out stale context, but it does not change how new streaming events are aggregated.
Can I just disable streaming?
Try it, but verify it. Some runner paths and versions may not honor the setting where you placed it. A config file containing streaming: false is not the same as runtime proof.
Can this patch delete real output?
It only collapses exact duplicates and exact doubled strings. Different content is not merged. Still, treat it as a temporary local workaround, not a permanent upstream fix.
Why do tool arguments duplicate too?
Tool-call arguments are streamed as fragments. If a full argument payload is counted after the fragments, the partial buffer can contain two JSON copies. The workaround includes an exact-doubled-string guard for partialArgs, but the cleaner fix belongs in the provider adapter.
Is this an OpenClaw bug or a gateway bug?
It is a compatibility boundary issue. The OpenAI streaming model centers on delta chunks. If a compatible gateway emits an extra full-message tail, the client should ignore or normalize it. OpenClaw should also avoid persisting obvious duplicates.
9. Closing notes
The important lesson is not the patch itself. The important lesson is the diagnostic order: prove whether duplication is only in TUI, then whether it is in the session file, then whether direct non-streaming and direct streaming behave differently, then whether raw OpenClaw stream events show repeated deltas.
Once the raw stream shows duplicate deltas, the minimal temporary fix is clear: back up installed files, fold exact duplicates before persistence, restart, and verify with a marker. When sharing the incident publicly, never include real internal endpoints, tokens, hostnames, session IDs, or private paths. Placeholders are enough.
References
- OpenAI API Reference: Chat Completions / Streaming: https://platform.openai.com/docs/api-reference/chat-streaming
- OpenAI Cookbook: How to stream completions: https://cookbook.openai.com/examples/how_to_stream_completions
- OpenClaw CLI Agent documentation: https://docs.openclaw.ai/cli/agent
- OpenClaw CLI Models documentation: https://docs.openclaw.ai/cli/models