In messages.visibleReplies "message_tool" sessions, a successful agent turn that produced a substantive private final without calling message(action=send) previously left the user with silence and only an operator log. The gateway now enqueues one protected front-of-queue retry prompting the model to deliver the reply, and falls back to a sanitized visible diagnostic when the retry cannot be enqueued or also strands. Queue overflow protection is unified with the in-flight-aware drop policy (skip in-flight or protected items, reject when nothing is droppable), rejected overflow no longer refreshes the drain debounce, heartbeat turns are excluded from recovery, and recovery retries no longer share the client turn's queued-turn lifecycle.
Fixes#85714
Thanks to Eva (@100yenadmin) for the contribution.
* fix(queue): prevent applyQueueDropPolicy from selecting in-flight items as overflow victims
When a burst of inbound messages hits the followup queue cap while the
head item is mid-delivery, applyQueueDropPolicy can select that same
in-flight item as an overflow victim. With the default
dropPolicy: "summarize", the in-flight item ends up recorded in the
overflow summary as dropped even though it is still being delivered,
producing a contradictory record where the same message is both
answered and reported as unanswered-due-to-overflow.
The fix introduces an optional `inFlight` Set parameter to
applyQueueDropPolicy and drainNextQueueItem. The followup queue state
now owns a shared inFlight set that is:
- Populated by drainNextQueueItem during the await run(next) window
- Populated by the collect-merge drain path for activeGroupItems
- Passed to applyQueueDropPolicy in enqueueFollowupRun
The drop policy now computes an effective queue length that excludes
in-flight items, and skips them when selecting splice victims.
Fixes#103246
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(queue): align pending depth with active deliveries
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(agents): keep merged delivery routes account-bound
mergeDeliveryContext gated route-field crossing on channel only, so a
completion origin that knew its account but not a concrete target
inherited a different account's to/threadId on the same channel. A
subagent, cron, or media completion for bot-a could be addressed to
bot-b's chat but sent through bot-a (cross-account misroute) or dropped.
This restores the account-bound guard added in 1ed8592467 and removed as
collateral by 025db6cf9e (PR #89949); same-account and missing-account
merges still backfill so the media route-pin path is preserved. Restores
the deleted regression test.
* fix(agents): centralize account-bound completion routes
---------
Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
* fix(utils): keep reply directive ids unicode-safe
* test(utils): catch trailing lone surrogate in hasUnpairedSurrogate helper
Per PR #96938 review: the test helper missed a high surrogate at end of
string because charCodeAt(out-of-bounds) returns NaN, and NaN comparisons
are always false. Guard bounds explicitly so the truncation test actually
proves what it claims, plus two cases pinning helper behavior.
* chore(utils): rerun QA smoke to confirm memory-index timeout flake on #96938
---------
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
* fix(exec): resume agent turn for native chat exec approvals (issue #93918)
Extend the inline approval-pending path that PR #85239 added for webchat to
every bundled chat channel that ships an `approval-handler.runtime`
adapter (Telegram, Discord, Slack, Signal, WhatsApp, iMessage, Matrix,
Google Chat, QQ Bot, plus webchat). When the originating turn can be
approved in the same chat, the gateway resolves the approval in place and
the agent waits inline for the command output instead of terminating the
run on the "approval-pending" tool result.
Before this fix, native chat approvals landed in the fire-and-forget
`sendExecApprovalFollowup` path. The followup either failed silently
against the agent dispatch and fell through to a direct delivery to the
operator, or never reached the agent at all; either way the model never
saw an "Exec running / Exec finished / Exec denied" event. The operator
had to send a follow-up message to recover the turn, and a new approval
was minted because the original run had already ended.
The change:
- Introduces `NATIVE_APPROVAL_CHANNELS` and `isNativeApprovalChannel`
in `src/utils/message-channel-constants.ts`, listing the channels that
ship a native chat approval client. `webchat` is included so the
single-channel check inside `shouldAwaitGatewayApprovalInline` can
move from "this one id" to "any native approval client".
- Replaces the `INTERNAL_MESSAGE_CHANNEL` equality check in
`shouldAwaitGatewayApprovalInline` with `isNativeApprovalChannel`,
preserving the `approvalFollowupMode` opt-out and the existing
`unavailableReason === null` gate.
- Adds unit tests asserting inline resolution and inline denial for
every native approval channel, plus a regression test that
non-native channels (e.g. feishu) and explicit `approvalFollowupMode`
settings still take the fire-and-forget path.
- Adds a `NATIVE_APPROVAL_CHANNELS` test in
`src/utils/message-channel.test.ts` to lock the membership and the
negative cases.
Refs https://github.com/openclaw/openclaw/issues/93918
* fix(lint): restore InternalMessageChannel type export lost during rebase
Rebase on upstream/main dropped the InternalMessageChannel type alias
from message-channel-constants.ts, breaking the plugin-sdk boundary
.dts check ('has no exported member named InternalMessageChannel').
message-channel.ts was also re-importing the type only to re-export
it, triggering the oxlint no-unused-vars rule.
- Re-add 'export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL'
in message-channel-constants.ts so the public re-export is valid.
- Drop the redundant 'type InternalMessageChannel' from the local
import in message-channel.ts; the value-side import is what the
file body actually needs.
* test(exec): align native approval routing expectations
* fix(status): render sub-1000 token counts as plain integers
formatKTokens always divided by 1000 and appended "k", so token counts
below 1000 rendered as misleading fractional k in `openclaw status`
output (e.g. 999 rounded up across the boundary to "1.0k", 420 -> "0.4k",
a 300-token cache write -> "write 0.3k").
Guard value < 1000 to render the plain rounded integer, matching the
canonical formatTokenCount convention (src/utils/usage-format.ts). The
>=1000 "k" behavior is unchanged. Adds focused regression tests for the
0/420/999/1000/12000 boundary and small-session/small-cache status lines.
Fixes#89735
* fix(status): reuse canonical token formatter
* refactor(status): extract lightweight token formatter
---------
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
drainNextQueueItem captured items[0], awaited the run, then shift()-ed
index 0 assuming it still held the item it ran. Concurrent inbound
messages mutate the same shared items array, and at or over cap
applyQueueDropPolicy splices items off the front, so a burst arriving
while item[0] is in flight can shift a different, still-undelivered
survivor into index 0. shift() then deletes that survivor: it is never
run and is not counted in the overflow summary, so the agent silently
ignores a message it should have answered.
Remove the item that actually ran by identity via a new
removeQueuedItemsByRef helper, and apply the same reference-based
removal to the collect path in drain.ts, which had the same positional
splice(0, groupItems.length) assumption after an awaited group run.
* fix(providers): use native reasoning mode for direct Gemini API, keep CLI tagged
Gemini 2.5+ delivers reasoning via native thinkingParts (thinkingConfig.
includeThoughts). Having tagged mode active at the same time injects a
<think>…</think>/<final>…</final> directive into the system prompt; the
model opens a <think> block before a tool call, never closes it, and
returns an empty post-tool turn (content:[], payloads=0 error, #69220).
Fix: override resolveReasoningOutputMode in buildGoogleProvider() only —
not in the shared GOOGLE_GEMINI_PROVIDER_HOOKS. The Gemini CLI backend
(google-gemini-cli) runs gemini --output-format json and parses a text
response field, not native thought parts; it must stay on tagged mode.
A regression test confirms google-gemini-cli remains "tagged".
Also remove the dead BUILTIN_REASONING_OUTPUT_MODES entry keyed on
"google-generative-ai" from provider-utils.ts — that string is only
ever the transport model.api value, never the provider id passed to
resolveReasoningOutputMode, so the map was unreachable.
Fixes#69220
* docs: clarify Gemini reasoning output modes
* fix(google): keep Antigravity reasoning tagged
* fix(google): default direct reasoning checks to native
* fix(google): import reasoning context from plugin entry
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Adds broad inline comments and JSDoc for CLI, cron, outbound/channel, plugin SDK, ACP, shared helpers, net policy, and related utility contracts. Proof: git diff --check on latest exact head plus focused cron tests passed; CI had no failing checks observed before merge attempt.
Extract shared normalization/coercion helpers into private @openclaw/normalization-core workspace package while preserving existing plugin SDK helper subpaths.\n\nAlso keeps direct normalization-core imports internal, wires UI/build/loader resolution, and replaces the slow PR network CodeQL lane with a fast added-line boundary scan while retaining full CodeQL for scheduled/manual runs.\n\nVerification: local moved tests, plugin SDK boundary tests, extension loader tests, agents-support shard, UI build/test, build artifacts, lint, workflow guards, autoreview, and GitHub CI passed on PR head 963d893715.