Skip to content

dispatch: broadcast inbound_claim to global plugin listeners#71988

Closed
flowolforg wants to merge 3 commits intoopenclaw:mainfrom
flowolforg:fix/broadcast-inbound-claim-v2
Closed

dispatch: broadcast inbound_claim to global plugin listeners#71988
flowolforg wants to merge 3 commits intoopenclaw:mainfrom
flowolforg:fix/broadcast-inbound-claim-v2

Conversation

@flowolforg
Copy link
Copy Markdown

Summary

Fixes #48434

  • Problem: Core dispatch only fires inbound_claim for plugin-bound conversations. Global listeners (listen-only plugins) never receive events for conversations where plugin dispatch fell through.
  • What changed: Added a broadcast block that fires inbound_claim to global plugin listeners when pluginFallbackReason is set (i.e. plugin dispatch fell through with missing_plugin or no_handler).
  • Guard: Uses pluginFallbackReason (not !pluginOwnedBinding) to avoid broadcasting for normal core conversations that were never plugin-bound.
  • What did NOT change: No changes to existing plugin dispatch, hook runner, or test files.

Supersedes #71644 (closed as dirty due to corrupted diff).

Change Type

  • Bug fix

Test Plan

  • Existing test does not broadcast inbound claims without a core-owned plugin binding validates that the guard prevents firing for non-plugin conversations.
  • CI checks: checks-node-auto-reply-reply-agent-dispatch and checks-node-core should pass.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 26, 2026

Greptile Summary

This PR adds a broadcast block in dispatch-from-config.ts that fires inbound_claim to global plugin listeners when a plugin-bound conversation falls back (pluginFallbackReason set). The intent is correct, but the implementation conflicts with existing test assertions: the "falls back to OpenClaw" tests for missing_plugin and no_handler both mock hasHooks(\"inbound_claim\") as true and assert runInboundClaim was not called — those assertions will now fail with this change.

Confidence Score: 3/5

Not safe to merge — existing tests for the exact fallback scenarios this PR targets will fail.

A P1 issue exists: the new broadcast guard condition is satisfied in at least three existing test cases that explicitly assert runInboundClaim is not called, causing test failures. The feature intent is sound but test coverage is incomplete and existing tests are broken.

src/auto-reply/reply/dispatch-from-config.ts — the broadcast block itself, and the corresponding test file which needs updated assertions and a new positive test.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/auto-reply/reply/dispatch-from-config.ts
Line: 651-661

Comment:
**Broadcast fires in existing fallback tests, breaking their assertions**

Two existing tests in `dispatch-from-config.test.ts` mock `hasHooks` to return `true` for `"inbound_claim"` and set `runInboundClaimForPluginOutcome` to return `missing_plugin` / `no_handler` (which is what sets `pluginFallbackReason`), then explicitly assert:
```
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
```
With this new block, both conditions on line 651 are satisfied in those tests, so `runInboundClaim` **is** called and those assertions will fail:

- "falls back to OpenClaw once per startup when a bound plugin is missing" (lines 2959 and 2986)
- "falls back to OpenClaw when the bound plugin is loaded but has no inbound_claim handler" (line 3044)

The tests need to be updated to either relax the `not.toHaveBeenCalled()` assertions or mock `hasHooks` to return `false` for `inbound_claim` in the pure-fallback scenarios. There is also no positive test that verifies the broadcast fires and is handled correctly in the intended scenario.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "dispatch: broadcast inbound_claim to glo..." | Re-trigger Greptile

Comment on lines +651 to +661
if (pluginFallbackReason && hookRunner?.hasHooks("inbound_claim")) {
const broadcastResult = await hookRunner.runInboundClaim(
inboundClaimEvent,
inboundClaimContext,
);
if (broadcastResult?.handled) {
markIdle("plugin_broadcast_claim");
recordProcessed("completed", { reason: "plugin-broadcast-handled" });
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Broadcast fires in existing fallback tests, breaking their assertions

Two existing tests in dispatch-from-config.test.ts mock hasHooks to return true for "inbound_claim" and set runInboundClaimForPluginOutcome to return missing_plugin / no_handler (which is what sets pluginFallbackReason), then explicitly assert:

expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();

With this new block, both conditions on line 651 are satisfied in those tests, so runInboundClaim is called and those assertions will fail:

  • "falls back to OpenClaw once per startup when a bound plugin is missing" (lines 2959 and 2986)
  • "falls back to OpenClaw when the bound plugin is loaded but has no inbound_claim handler" (line 3044)

The tests need to be updated to either relax the not.toHaveBeenCalled() assertions or mock hasHooks to return false for inbound_claim in the pure-fallback scenarios. There is also no positive test that verifies the broadcast fires and is handled correctly in the intended scenario.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/auto-reply/reply/dispatch-from-config.ts
Line: 651-661

Comment:
**Broadcast fires in existing fallback tests, breaking their assertions**

Two existing tests in `dispatch-from-config.test.ts` mock `hasHooks` to return `true` for `"inbound_claim"` and set `runInboundClaimForPluginOutcome` to return `missing_plugin` / `no_handler` (which is what sets `pluginFallbackReason`), then explicitly assert:
```
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
```
With this new block, both conditions on line 651 are satisfied in those tests, so `runInboundClaim` **is** called and those assertions will fail:

- "falls back to OpenClaw once per startup when a bound plugin is missing" (lines 2959 and 2986)
- "falls back to OpenClaw when the bound plugin is loaded but has no inbound_claim handler" (line 3044)

The tests need to be updated to either relax the `not.toHaveBeenCalled()` assertions or mock `hasHooks` to return `false` for `inbound_claim` in the pure-fallback scenarios. There is also no positive test that verifies the broadcast fires and is handled correctly in the intended scenario.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in second commit (54f0a75): updated the three test assertions at lines 2959, 2986, and 3044 from .not.toHaveBeenCalled() to .toHaveBeenCalledTimes(1). All checks-node-auto-reply-reply-dispatch tests pass.

The only remaining CI failure is checks-fast-protocol which is unrelated to this PR — it flags out-of-date generated Swift protocol files (modelrun/promptmode added to AgentParams on main without re-running pnpm protocol:gen).

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e000107aaa

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +657 to +659
markIdle("plugin_broadcast_claim");
recordProcessed("completed", { reason: "plugin-broadcast-handled" });
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Commit dedupe state before returning on broadcast claim

When pluginFallbackReason is set and a global inbound_claim handler returns { handled: true }, this new early return exits before the normal dedupe cleanup path (commitInboundDedupe in the success tail, or releaseInboundDedupe in catch). That leaves the claimed key stuck in the in-flight set, so retries of the same inbound message are treated as permanently inflight and skipped. This regression is specific to plugin-bound fallback turns handled by the new broadcast block.

Useful? React with 👍 / 👎.

Comment on lines +656 to +657
if (broadcastResult?.handled) {
markIdle("plugin_broadcast_claim");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Send synthetic reply from broadcast inbound_claim handlers

The broadcast path now treats broadcastResult.handled as terminal but ignores broadcastResult.reply. inbound_claim is documented/typed to support synthetic replies, and the targeted plugin-bound path already delivers result.reply, so a global listener that handles via { handled: true, reply: ... } will silently drop its reply here. In fallback conversations this can produce a handled turn with no plugin response.

Useful? React with 👍 / 👎.

@hxcdaniel-coder

This comment was marked as low quality.

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 26, 2026

Codex review: keeping this open for maintainer follow-up; there is still a little grit to resolve.

Keep this PR open. Current main still does not implement the requested fallback inbound_claim broadcast: plugin-bound fallback records a reason and then proceeds directly to message_received hooks and normal dispatch. The PR is the active focused successor to the closed report and earlier dirty PR, and the latest discussion/patch addresses at least the prior dedupe and test-coverage concerns. It still needs maintainer review for the synthetic-reply behavior before merge.

Best possible solution:

Keep the PR open for normal maintainer review. The best path is to land a narrow dispatch fix that broadcasts inbound_claim only after plugin-bound missing_plugin or no_handler fallback, keeps inbound dedupe cleanup on every terminal return, and either delivers broadcastResult.reply consistently with the targeted plugin-bound path or documents/tests that fallback broadcasts are observe/absorb-only for now.

What I checked:

Remaining risk / open question:

  • Merging the current PR without handling or explicitly ruling out broadcastResult.reply would make global inbound_claim handlers able to return a handled synthetic reply that is silently dropped on the new fallback broadcast path.
  • Closing this PR would drop the current active implementation path for a still-missing core dispatch integration requested by the closed feature report.

Codex review notes: model gpt-5.5, reasoning high; reviewed against f76c8322d302.

…penclaw#48434)

When plugin dispatch falls through (missing_plugin or no_handler), broadcast the inbound_claim hook to global listeners so listen-only plugins can observe unbound inbound messages. Guarded by pluginFallbackReason to avoid firing for normal core conversations.
- Commit inbound dedupe before the broadcast-handled early return so
  the claim does not stay pinned in inFlight forever (Codex review
  concern). The other plugin-bound early returns above have the same
  latent issue but are out of scope for this PR.
- Add positive test for the broadcast-handled short-circuit path
  (Greptile P1 / QC review request): asserts runInboundClaim is called
  exactly once, runMessageReceived is not invoked, and the agent path
  is not entered.
@flowolforg flowolforg force-pushed the fix/broadcast-inbound-claim-v2 branch from 54f0a75 to 9176cc0 Compare April 28, 2026 08:34
@flowolforg
Copy link
Copy Markdown
Author

Rebased onto main and addressed reviewer feedback in 9176cc07e2.

checks-fast-protocol failure: caused by modelRun/promptMode schema regen that landed on main after this branch forked. Rebase pulls the regenerated GatewayModels.swift and protocol.schema.json into the branch and resolves the failure deterministically — verified locally before pushing.

Greptile P1 / QC: positive test for the broadcast-handled short-circuit added (short-circuits when a global plugin handles the inbound_claim broadcast). Asserts runInboundClaim is called exactly once, runMessageReceived is not invoked, replyResolver is not called, and the result reflects an early return.

Codex — dedupe cleanup: the broadcast-handled early return now commits inboundDedupe so the claim does not stay pinned in inFlight forever. Note the three pre-existing plugin-bound early returns above (handled, declined, error) have the same latent issue — flagging that as a separate cleanup PR rather than expanding this one's scope.

Codex — synthetic-reply delivery: broadcastResult.reply for unbound conversations needs a different delivery path than sendBindingNotice (which is binding-scoped). The listen-only / spam-filter use cases that motivated #48434 don't emit replies, so deferring this as a follow-up rather than blocking this fix.

@flowolforg
Copy link
Copy Markdown
Author

CI status update for 9176cc07e2:

Resolved: checks-fast-protocol is now green — the rebase pulled in the regenerated schema files that had landed on main after this branch forked.

Test of interest: checks-node-auto-reply-reply-dispatch is green, including the new positive short-circuits when a global plugin handles the inbound_claim broadcast test.

Two failures remain, both pre-existing on main and unrelated to this PR:

Job This PR main 474859aaaa main 8ff0ea50b0
checks-node-core-support-boundary failure failure (run) failure (run)
checks-node-core (aggregator of the above) failure failure failure

Both failures reproduce on multiple recent main pushes without any code from this branch involved. checks-node-auto-reply-reply-dispatch (the test scope this PR actually touches) is green on those same main runs.

mergeStateStatus reads UNSTABLE because of the pre-existing failures — formally not clean, but the failures are not blockers introduced here.

@flowolforg
Copy link
Copy Markdown
Author

Closing — superseded by before_dispatch (#50444), which already covers the listen-only / spam-filter / rate-limiter use cases that motivated #48434. Our extensions/listen-only/ plugin migrated to before_dispatch in v2026.4.5 and works without any core changes.

inbound_claim remains plugin-bound-only by design (semantics: a plugin claims an existing plugin-bound conversation). Broadcasting it to global listeners would blur its semantic with before_dispatch without adding capability.

Thanks to the reviewers (Greptile, Codex/clawsweeper, QC) — all concerns are moot now that the use case is covered by an existing, purpose-built hook.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Broadcast inbound_claim hook to all plugins (not just plugin-bound conversations)

2 participants