Summary:
- The PR imports `truncateUtf16Safe` and uses it for the Codex usage-limit preview and verbose working-label truncation paths in auto-reply.
- PR surface: Source +2. Total +2 across 2 files.
- Reproducibility: yes. Current main uses raw `slice(0, N)` at both reported user-facing truncation sites, and ... ludes terminal before/after output showing dangling surrogates before the fix and safe truncation after it.
Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.
Validation:
- ClawSweeper review passed for head 74a0a32ed9.
- Required merge gates passed before the squash merge.
Prepared head SHA: 74a0a32ed9
Review: https://github.com/openclaw/openclaw/pull/97299#issuecomment-4820038635
Co-authored-by: zenglingbiao <zeng.lingbiao@xydigit.com>
Approved-by: takhoffman
* fix(model-fallback): don't rethrow provider-side AbortErrors as user cancellations
When the LLM API closes the connection mid-stream, the fetch layer
surfaces AbortError("This operation was aborted") with no external
abort signal triggered. The old guard `shouldRethrowAbort()` returned
false for these errors (because isTimeoutError matched the message),
so they fell through to the fallback loop but were never retried —
the error propagated up and produced SILENT_REPLY_TOKEN in group
sessions, permanently silencing the topic.
Replace the guard with a direct check: only rethrow AbortError when
the external abort signal is actually set (user/gateway cancellation).
Provider-side AbortErrors without an external signal now fall through
to the next fallback candidate, giving the system a chance to recover.
* fix(cron): forward abort signal into runWithModelFallback
Thread the cron executor's abort signal into the shared
runWithModelFallback call so that cron timeouts and cancellations
stop the fallback chain instead of retrying with the next candidate.
Previously, the run callback checked params.abortSignal?.aborted and
threw, but runWithModelFallback itself had no signal — so the new
guard in model-fallback.ts could not distinguish a caller abort from
a provider-side AbortError and would retry silently.
Also adds a focused regression test verifying the signal is forwarded.
---------
Co-authored-by: Shengting Xie <shengting@openclaw.ai>
Co-authored-by: yayu <yayu@yayuMacStudio.local>
* fix(provider-transport-fetch): bound SSE buffer to prevent OOM
* fix(provider-transport-fetch): appease oxlint curly rule in test
* fix(provider-transport-fetch): drain events before cap + cancel reader on overflow
* fix(provider-transport-fetch): remove unused encoder from coalesced chunk test
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(transport): tighten SSE buffer guards
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
* fix(openai-chatgpt-responses): bound streaming success-body SSE reads at 16 MiB
Apply createSseByteGuard to src/llm/providers/openai-chatgpt-responses.ts
(parseSSE) so the ChatGPT Responses SSE parser cannot be exhausted by a
hostile or malfunctioning endpoint that streams an unbounded SSE body.
The 16 MiB cap matches the non-streaming readProviderJsonResponse cap.
What changed:
- src/llm/providers/openai-chatgpt-responses.ts: wrap body.getReader() in
createSseByteGuard before the existing parseSSE loop. The function is
also re-exported as parseSSEForTest for direct test access.
- Inline test added to openai-chatgpt-responses.test.ts: hostile 1 MiB
pull stream, asserts canonical overflow message, cancel(reason) was an
Error instance, pullCount bounded to 17-20.
Reuses the createSseByteGuard helper from src/agents/streaming-byte-guard.ts
(introduced in #96701 for the anthropic-transport-stream path; same
helper, same 16 MiB cap, same overflow-error shape). This PR is the
third of the planned per-surface rescue series for the previously closed
PR #96666, after PR #3b (#96723).
No SDK surface change. No repro script committed (proof in this body).
* fix(openai): bound ChatGPT error body reads
* fix(openai): bound chatgpt error parsing
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
* fix(agents): truncate exec command detail on code-point boundaries
compactRawCommand in src/agents/tool-display-exec.ts middle-truncated the
one-line command with raw String.prototype.slice. When the head or tail
boundary fell between the two UTF-16 code units of a surrogate pair (e.g. an
emoji like U+1F600), the slice kept a lone surrogate, which renders as the
replacement character in the tool-call summary shown in chat/transcripts.
Use the existing sliceUtf16Safe helper for both ends so the boundary falls on
a code-point boundary, dropping the whole emoji instead of half of it. This is
behavior-preserving for non-surrogate input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: move surrogate-safe slice helpers to browser-safe module
ClawSweeper flagged that importing sliceUtf16Safe from src/utils.ts into
tool-display-exec.ts pulls node:fs/os/path into the Control UI browser-shared
bundle (ui/vite.config.ts treats tool-display-exec.ts as browser-shared).
Move sliceUtf16Safe/truncateUtf16Safe into a self-contained, dependency-free
module src/shared/text/surrogate-safe-slice.ts. src/utils.ts re-exports them
(zero churn for existing Node-side callers), and tool-display-exec.ts now
imports directly from the node-free module so no Node built-ins can leak into
the browser bundle.
* fix(agents): use shared utf16 helper in exec display
---------
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
registerNodesCli unconditionally registered plugin CLI commands, so
lightweight built-in commands like `nodes status`/`nodes list` paid the
full plugin CLI/runtime load cost. Only resolve plugin-provided node
subcommands (e.g. `nodes canvas`) when the invoked subcommand is not
already a built-in, keeping the built-in nodes path fast.
Fixes#96697
* fix(agents): truncate tool-display detail on code-point boundaries
coerceDisplayValue truncated a long first-line detail value with raw
UTF-16 slice() at half = floor((maxStringChars-1)/2). When an emoji
(surrogate pair) straddles the cut boundary, the head kept a lone high
surrogate and the tail could begin on a lone low surrogate, rendering as
the replacement character. Use the existing sliceUtf16Safe helper so the
whole code point is dropped at the boundary, matching the UTF-16-safe
truncation used elsewhere in the repo. Behavior-preserving for
non-surrogate input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(build): import surrogate-safe slice from node-free leaf module
tool-display-common.ts is in the UI browser bundle graph; importing
sliceUtf16Safe from utils.js dragged node:fs (via infra/fs-safe) into the
bundle and broke build-artifacts/QA Smoke. Extract sliceUtf16Safe/truncateUtf16Safe
into src/shared/utf16-slice.ts (dependency-free) and re-export from utils.js to
preserve the existing import surface.
* fix(build): import surrogate-safe slice from node-free leaf module
* fix(build): import surrogate-safe slice from node-free leaf module
* fix(build): import surrogate-safe slice from node-free leaf module
---------
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
* fix(mcp): emit image content with base64 source
* fix(mcp): keep plugin tool images in SDK schema
* test(mcp): exercise image bridge end to end
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(auth): suppress recovery hint for format failures
* test(auth): cover format failure recovery copy
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* perf(update): reuse missing plugin payload id set
* perf(update): reuse missing plugin payload id set
---------
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
chunkByNewline length-splits a single over-long line that has no usable break
point. The first head cut used a raw UTF-16 slice (lineValue.slice(0,
firstLimit)), so when firstLimit landed inside a surrogate pair it emitted a
chunk ending in a lone high surrogate and a next chunk starting with a lone low
surrogate, which render as U+FFFD on delivery. The recursive chunkText that
handles the remainder is already surrogate-safe; only this first cut was raw.
Route the head cut through avoidTrailingHighSurrogateBreak (the same helper
chunkText and chunkMarkdownText already use) so the cut backs off to a
code-point boundary. ASCII and non-surrogate cuts are unaffected. Reproduces at
the production-default 4096 limit for emoji-dense lines; chunkByNewline is the
published plugin-SDK channel.text helper, reachable with arbitrary outbound text.
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>