fix(reply): preserve pending thread evidence when reconciling partial send results#93291
Conversation
|
Codex review: needs maintainer review before merge. Reviewed June 15, 2026, 3:35 PM ET / 19:35 UTC. Summary PR surface: Source +10, Tests +97. Total +107 across 2 files. Reproducibility: yes. Source inspection shows current main drops pending thread fields when a provider result omits them, Mattermost can omit Review metrics: none identified. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Mantis proof suggestion Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land the shared reconciler fix with the core-local regression test; live Mattermost proof is an optional maintainer confidence check, not a required code repair from this review. Do we have a high-confidence way to reproduce the issue? Yes. Source inspection shows current main drops pending thread fields when a provider result omits them, Mattermost can omit Is this the best way to solve the issue? Yes. The shared reconciler is the narrowest maintainable fix boundary because native, CLI, and Codex callers use it; a Mattermost-only patch would leave the generic partial-result contract broken. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against b3128ba93def. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +10, Tests +97. Total +107 across 2 files. View PR surface stats
What 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
|
|
@clawsweeper re-review please. Rank-up since the last push:
Verification after the change:
The production fix in extractMessagingToolSendResult is unchanged from the prior review. |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
… 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).
…ttermost 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.
ca07847 to
3099983
Compare
|
Land-ready verification:
The patch atomically selects provider-reported or pending thread evidence, preserving implicit/suppressed thread facts for partial provider results while allowing explicit provider thread results to win. Known gap: no live Mattermost server run; the real production reconciler and reply-suppression path are covered directly. |
… 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 47e4bdb (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]>
… 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]>
Summary
In a Mattermost thread, when the agent posts into the current channel via the
messagetool (implicit threading, no explicitreplyToId) and then emits its final composed reply, the user receives the reply twice in the thread. This hits both the native embedded-agent harness and the Codex harness.The post-send reconciler
extractMessagingToolSendResultre-derives the thread fields from the provider result and erases the pending send's thread evidence. This change makes a partial provider result keep the pending thread evidence it never speaks to, so same-thread reply suppression works again.Root cause
extractMessagingToolSendResult(src/agents/embedded-agent-subscribe.tools.ts) reconciles the pending send evidence with the provider'sextractToolSendResult, but unconditionally re-derives the thread fields from the provider result:Mattermost is the only production provider that implements
extractToolSendResult, andextractMattermostToolSendResult(extensions/mattermost/src/channel.ts) returns only{ to, threadId? }, withthreadIdpresent only when an explicitreplyToIdwas passed. So for an implicitly threaded send the provider result is just{ to }, and the three lines above overwrite the correct pendingthreadId: "root-1"+threadImplicit: truewithundefined.Mattermost's outbound adapter has no
targetsMatchForReplySuppressionmatcher, so once the thread id is gone the dedupe guard insrc/auto-reply/reply/reply-payloads-dedupe.tsbails (originRoute.threadId != nullbuttargetRoute.threadIdis nownull) and returns no match. The final reply is therefore not suppressed and is delivered on top of the tool send.This was introduced by c67dc59 (#90943): the call site in
embedded-agent-subscribe.handlers.tools.tschanged from pushing...pendingTarget(full evidence) to...confirmedTarget(reconciled). The same reconciler is also invoked from the Codex app-server (extensions/codex/src/app-server/dynamic-tools.ts), so the defect reaches both harnesses.Fix
A partial provider result must not erase pending evidence it never reports:
When the provider reports an explicit thread it still wins (and clears the implicit flag, since an explicit thread is not implicit); when it does not, the pending
threadId/threadImplicit/threadSuppressedsurvive.Why not one-sided
extractMessagingToolSendResult, the single chokepoint used by both call sites (the nativeembedded-agent-subscribe.handlers.tools.tsand the Codexextensions/codex/src/app-server/dynamic-tools.ts), so both harnesses are covered by one change.extractToolSendResultreturns a partial result (Mattermost today) keeps its pending thread evidence, while a provider that does report a thread still has it honored.threadIdoverrides pending evidence and clearsthreadImplicit, so genuine thread changes are not masked.Verification
node scripts/run-vitest.mjs run src/agents/embedded-agent-subscribe.tools.reconcile-thread.test.ts src/agents/embedded-agent-subscribe.tools.extract.test.ts src/auto-reply/reply/reply-payloads.test.ts extensions/codex/src/app-server/dynamic-tools.test.ts-> 100 passed (adds 2 regression tests: implicit evidence preserved through a partial result and then suppressed by dedupe; explicit provider thread still overrides). The new test fails on pristine main (expected undefined to be true) and passes with this patch.oxfmt --checkon the changed files -> clean.oxlint --type-aware --tsconfig config/tsconfig/oxlint.core.jsonon the changed files -> 0 findings.tsgo:core+tsgo:core:test-> clean.Real behavior proof
Behavior addressed: an implicitly threaded Mattermost
message-tool send no longer loses its thread evidence during reconciliation, so the agent's final reply is suppressed by same-thread dedupe instead of being delivered a second time in the thread.Real environment tested: a throwaway harness (not committed) drove the real production functions from this branch end to end with the real
mattermostPluginregistered:extractMessagingToolSend->extractMessagingToolSendResult->getMatchingMessagingToolReplyTargets, run against the pristine build and the patched build.Exact steps or command run after this patch: register the Mattermost plugin, build the pending send for an implicit reply into thread
root-1onchannel:abc(no explicitreplyToId), reconcile it against the real Mattermost-shaped result{ details: { toolSend: { to: "channel:abc" } } }, then ask the real dedupe whether the final reply intoroot-1is suppressed.Evidence after fix:
Observed result after fix: the same implicit-thread send whose reconciled target previously dropped to
threadImplicit=undefined/threadId=undefined(yielding 0 suppression matches, a duplicate reply) now keepsthreadImplicit=true/threadId="root-1"and produces 1 suppression match, so the final reply is suppressed.What was not tested: a live end-to-end run against a real Mattermost server; this change is at the reconciler/dedupe layer and is covered above by driving the real runtime functions plus the colocated regression coverage on both the native and Codex test suites.
Regression introduced by c67dc59 (#90943).