fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread#90943
Conversation
|
Codex review: needs real behavior proof before merge. Reviewed June 14, 2026, 11:51 AM ET / 15:51 UTC. Summary Reproducibility: yes. source-reproducible: the linked reports describe transcript-success/platform-delivery mismatch, latest release still has the route/thread dedupe gap, and PR-head CI reproduces a new routed ACP TypeError. I did not see live after-fix Slack or Mattermost proof. Review metrics: 2 noteworthy metrics.
Stored data model Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof guidance:
Mantis proof suggestion Risk before merge
Maintainer options:
Next step before merge
Security Review findings
Review detailsBest possible solution: Repair the current head, prove the fix in a live redacted Slack or Mattermost transport flow, and land one maintainer-approved route/thread reply fix with the intended SDK contract documented or narrowed. Do we have a high-confidence way to reproduce the issue? Yes, source-reproducible: the linked reports describe transcript-success/platform-delivery mismatch, latest release still has the route/thread dedupe gap, and PR-head CI reproduces a new routed ACP TypeError. I did not see live after-fix Slack or Mattermost proof. Is this the best way to solve the issue? No, not yet: the route/thread-aware owner boundary is plausible, but the current implementation crashes ACP routed delivery and still needs live transport proof plus maintainer approval of the expanded SDK/gateway contract. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 2e240e772b83. Label changesLabel justifications:
Evidence reviewedWhat I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
… scope dedupe to routed thread Two core bugs caused composed replies to be silently dropped (no delivery, no error) when a second message arrived in the same thread mid-run: 1. dispatch-from-config: ensureDispatchReplyOperation only kept the dispatch-owned operation authoritative while it had no result. Once runReplyAgent completed the operation to drain queued follow-ups, a second same-thread inbound could claim the session and the first final reply would try to re-acquire the lane instead of finishing delivery, deadlocking behind the queued work. Keep the dispatch-owned operation authoritative through final delivery. 2. reply-payloads-dedupe: messaging-tool reply dedupe compared only the channel target, not the routed thread, so a send in one thread could suppress a later reply in a different thread. Thread the routed thread id through buildReplyPayloads + follow-up delivery and only fall back to channel-only matching for providers without a thread-aware suppression matcher when neither side carries thread evidence. Adds regression tests; existing Telegram topic-suppression behavior is preserved by gating the thread guard to providers lacking a plugin matcher.
… send results (openclaw#93291) * fix(reply): preserve pending thread evidence when reconciling partial send results extractMessagingToolSendResult re-derived threadId/threadImplicit/threadSuppressed straight from the provider result. Mattermost is the only production provider that implements extractToolSendResult, and for an implicitly threaded send it reports only { to }, so the reconciler overwrote the correct pending thread evidence with undefined. That defeated same-thread reply suppression in reply-payloads dedupe and delivered the agent's final reply twice in the thread, on both the native and Codex harnesses. A partial provider result now keeps the pending thread evidence it does not speak to: a provider-reported threadId still wins (and clears the implicit flag), but an absent one no longer erases the pending threadId/threadImplicit/threadSuppressed. Regression introduced by c67dc59 (openclaw#90943). * test(reply): use a core-local stub provider instead of the bundled Mattermost import The reconcile-thread regression test deep-imported extensions/mattermost from a core test, which trips the core/extension package boundary (boundary-invariants "keeps core tests off bundled extension deep imports", extension-test-boundary, and check-tsgo-core-boundary pulling extensions/mattermost transitively). Replace it with a core-local channel test plugin that reproduces the same contract: an implicit-threading extractToolSend, a partial extractToolSendResult that reports only { to, threadId? }, and no targetsMatchForReplySuppression matcher. The test now exercises the generic reconciler contract with no extension dependency. It still fails on pristine main and passes with the fix. * fix(reply): reconcile thread evidence atomically --------- Co-authored-by: Vincent Koc <[email protected]>
… scope dedupe to routed thread (openclaw#90943) * fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread Two core bugs caused composed replies to be silently dropped (no delivery, no error) when a second message arrived in the same thread mid-run: 1. dispatch-from-config: ensureDispatchReplyOperation only kept the dispatch-owned operation authoritative while it had no result. Once runReplyAgent completed the operation to drain queued follow-ups, a second same-thread inbound could claim the session and the first final reply would try to re-acquire the lane instead of finishing delivery, deadlocking behind the queued work. Keep the dispatch-owned operation authoritative through final delivery. 2. reply-payloads-dedupe: messaging-tool reply dedupe compared only the channel target, not the routed thread, so a send in one thread could suppress a later reply in a different thread. Thread the routed thread id through buildReplyPayloads + follow-up delivery and only fall back to channel-only matching for providers without a thread-aware suppression matcher when neither side carries thread evidence. Adds regression tests; existing Telegram topic-suppression behavior is preserved by gating the thread guard to providers lacking a plugin matcher. * fix(reply): preserve threaded message delivery evidence * fix(reply): dedupe final payloads by delivery route * fix(slack): preserve native send thread evidence * fix(reply): preserve explicit reply thread evidence * fix(reply): align explicit reply route dedupe * fix(reply): preserve delivery lane through final dispatch * fix(mattermost): preserve threaded tool send routes * chore(plugin-sdk): refresh API baseline * fix(reply): align final delivery route dedupe * fix(reply): gate followups on final delivery * fix(reply): keep send receipts private * fix(reply): infer implicit message provider * fix(reply): align routed threading policy * fix(reply): preserve queued delivery context * fix(reply): hydrate queued system event routes * fix(reply): hydrate queued execution routes * fix(reply): scope final delivery barriers * fix(slack): preserve DM target aliases * fix(reply): mirror resolved source thread routes * fix(mattermost): retain delayed delivery barrier * fix(codex): separate message routing from tool policy * fix(reply): consume normalized Slack DM targets once * fix(slack): remove stale target alias * style(reply): satisfy changed lint gates * fix(mattermost): preserve explicit reply targets * test: align Slack reply branch checks * fix(reply): persist overflow summaries to admitted session --------- Co-authored-by: Peter Steinberger <[email protected]>
Summary
Fix silent final-reply drops when a queued follow-up claims the same session, and make message-tool/final-reply dedupe use the actual delivered route rather than only the ambient channel.
The repaired stack now:
This consolidates the stronger route/thread handling from #90045 and covers the failure reports in #80715 and #92443.
Verification
pnpm buildpnpm plugin-sdk:api:checkgit diff --checkOPENCLAW_STATE_DIR=<temp> pnpm openclaw message send --channel slack --target user:U123 --message "thread route probe" --dry-run --jsonU123, handled by core--deliver --reply-channel slackreported success; readback preserved the same complete rich-text structureReal behavior proof
Behavior addressed: Final replies could disappear when queued follow-ups claimed the session, and equivalent Slack targets or cross-thread sends could be deduped against the wrong route.
Real environment tested: Current OpenClaw checkout on Node, Testbox changed-lane validation, and a real Slack workspace through both direct OpenClaw sending and agent delivery.
Exact steps or command run after this patch: Ran the focused 20-file Vitest command, the queue rotation regression,
pnpm build,pnpm plugin-sdk:api:check, Testboxpnpm check:changed, the Slackuser:U123dry-run command above, a direct Slack send/read/delete, and an agent delivery/read/delete.Evidence after fix: 786 focused tests and the 56-test queue regression passed; build and plugin API checks passed; CLI normalized
user:U123toU123; Testbox run is linked above. Redacted live output:{ "agentDelivery": { "deliverySucceeded": true, "deliveryStatus": { "status": "sent", "succeeded": true } }, "slackReadback": { "quoteBlocks": 2, "inlineCodeStyle": true, "endMarkerPresent": true }, "cleanup": { "temporaryMessagesDeleted": true } }Observed result after fix: Final delivery retains its owned lane, queued work waits for delivery settlement, dedupe compares the provider-resolved channel/thread route, and both live Slack delivery paths returned complete visible content.
What was not tested: No cross-OS live Slack delivery was run; Testbox covers the Linux changed-lane checks.