Skip to content

feat(plugins): add before_message_process hook with first-claim-wins semantics#50366

Closed
xyzphp wants to merge 6 commits intoopenclaw:mainfrom
xyzphp:feat/before-message-process-hook
Closed

feat(plugins): add before_message_process hook with first-claim-wins semantics#50366
xyzphp wants to merge 6 commits intoopenclaw:mainfrom
xyzphp:feat/before-message-process-hook

Conversation

@xyzphp
Copy link
Copy Markdown

@xyzphp xyzphp commented Mar 19, 2026

Summary

  • Problem: the plugin SDK has no message interception hook before the AI agent runs.
    The existing message_received hook is observational (fire-and-forget) and cannot
    stop downstream agent processing.
  • Why it matters: routing plugins (e.g. customer-service plugins)
    often need to forward inbound messages to external systems before the AI agent replies.
    In addition, some plugins may need to perform lightweight pre-processing such as
    authentication checks, request validation, or filtering, and decide whether to skip
    AI processing entirely.
  • What changed: this adds a new before_message_process hook that runs after message
    routing but before the AI agent starts. Plugins can use this hook to intercept
    messages for forwarding, filtering, or pre-checks (e.g. authentication). If a plugin
    returns { handled: true }, the message is intercepted and AI processing is skipped.
    Multiple handlers run sequentially with first-claim-wins semantics.
  • What did NOT change: existing hook behavior is unchanged. This does not introduce a
    full authentication/authorization system and does not modify ACP dispatch, tool
    execution, session lifecycle, or message routing semantics.

Relationship with #43422 (before_dispatch)

#43422 proposes a before_dispatch hook for authentication gate scenarios. This PR
takes a different approach:

Aspect #43422 (before_dispatch) This PR (before_message_process)
Primary use case Authentication/authorization Customer-service routing & pre-processing
Event fields sessionKey, channelId, conversationId, content, isGroup, messageId from, content, body, bodyForAgent, transcript, timestamp, senderId, senderName, isGroup, messageId
Return value { block?: boolean, replyText?: string } { handled?: boolean, reason?: string }
Reply capability Can send custom reply when blocked Intercept only (no reply)
Session requirement Requires sessionKey Works with or without session
Why both? before_message_process provides richer inbound message context
(body, transcript, senderName, etc.) for customer-service plugins that need to
forward complete context to external systems.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

Plugins can now register api.on("before_message_process", handler):

api.on("before_message_process", async (event, ctx) => {
  const forwarded = await sendToExternalSystem(event, ctx);
  if (forwarded) {
    return { handled: true, reason: "forwarded to kefu" };
  }
  // return undefined / void / { handled: false } to continue normal AI processing
});
  • Returning { handled: true } intercepts the inbound message and prevents AI agent processing.
  • Returning undefined, void, or { handled: false } lets the message continue through the normal AI path.
  • When multiple plugins register this hook, the first plugin that returns handled: true wins and later handlers are not called.

Security Impact (required)

  • New permissions/capabilities? (Yes/No) Yes
  • Secrets/tokens handling changed? (Yes/No) No
  • New/changed network calls? (Yes/No) No
  • Command/tool execution surface changed? (Yes/No) No
  • Data access scope changed? (Yes/No) No
  • If any Yes, explain risk + mitigation:
    • This adds a new interception capability for plugins, but it does not expand data access or token scope.
    • The hook only allows a plugin to short-circuit AI processing for the current inbound message.
    • First-claim-wins semantics ensure that at most one plugin intercepts a message.
    • Interceptions are logged with an optional reason to make debugging easier.

Repro + Verification

Environment

  • OS: macOS / Linux / Windows
  • Runtime/container: Node 22+
  • Model/provider: any
  • Integration/channel (if any): any configured channel such as Telegram or Discord
  • Relevant config (redacted): none

Steps

  1. Install a plugin that registers before_message_process and returns { handled: true }.
  2. Restart the gateway.
  3. Send an inbound message through a configured channel.

Expected

  • The AI agent does not process the message.
  • The dispatcher returns without queuing a final AI reply.
  • Logs show that the message was intercepted by before_message_process.

Actual

  • Added focused test coverage for the new hook path:
    • src/plugins/wired-hooks-before-message-process.test.ts covers no-hook pass-through, handled: true interception, handled: false pass-through, void pass-through, first-claim-wins behavior, second-handler fallback, and full event field propagation.
    • src/auto-reply/reply/dispatch-from-config.test.ts adds dispatch-path coverage for handled: true short-circuiting, undefined continuing into the AI path, and handled: false continuing into the AI path.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Relevant coverage:

  • src/plugins/wired-hooks-before-message-process.test.ts
    • no handlers registered
    • handled true intercepts
    • handled false and void pass through
    • first-claim-wins across multiple handlers
    • full event field propagation
  • src/auto-reply/reply/dispatch-from-config.test.ts
    • skips AI agent when the hook intercepts
    • continues normal AI processing when the hook does not intercept

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: reviewed the dispatch path and added tests for both intercept and pass-through behavior.
  • Edge cases checked: handled: false, undefined, no registered hook, and multiple handlers with first-claim-wins behavior.
  • What you did not verify: production performance under high concurrency with many plugins installed.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.

Compatibility / Migration

  • Backward compatible? (Yes/No) Yes
  • Config/env changes? (Yes/No) No
  • Migration needed? (Yes/No) No
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: remove the plugin registration for before_message_process, or revert the hook dispatch block in dispatch-from-config.ts.
  • Files/config to restore: src/auto-reply/reply/dispatch-from-config.ts, src/plugins/hooks.ts, src/plugins/types.ts
  • Known bad symptoms reviewers should watch for: inbound messages arrive but the AI agent does not reply because a plugin unexpectedly returned { handled: true }.

Risks and Mitigations

  • Risk: a plugin could accidentally intercept messages and suppress AI responses.
    • Mitigation: interception requires an explicit { handled: true } return value, uses first-claim-wins semantics, and logs an optional reason for debugging.

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation size: M labels Mar 19, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 19, 2026

Greptile Summary

This PR introduces a new before_message_process plugin hook that fires after message routing but before AI agent processing, enabling plugins to intercept messages with first-claim-wins semantics. The implementation follows established patterns (runClaimingHook, sequential execution, { handled: true } short-circuit) and is well-tested at the runner level.

Key findings:

  • Critical — broken test setup: runBeforeMessageProcess is used in the beforeEach of dispatch-from-config.test.ts (.mockClear() / .mockResolvedValue()) but is not declared in the initial hookMocks.runner object. This will cause a TypeError on every test in that file after this change lands, breaking the entire suite.
  • Behavioral gap — ACP bypass: The hook is placed after tryDispatchAcpReply returns early, meaning it is silently skipped for all ACP-dispatched messages. This is not documented and may surprise plugin developers who expect the hook to fire for every inbound message.
  • Minor — unused metadata field: PluginHookBeforeMessageProcessEvent.metadata is declared in the type but is never populated at the call site (the canonical hook context has no metadata), so it will always be undefined. The field should either be populated or removed to avoid a misleading API.

Confidence Score: 2/5

  • Not safe to merge — the test file has a broken mock setup that will cause a TypeError and fail the existing test suite.
  • The core hook implementation and type definitions are sound and follow existing patterns. However, runBeforeMessageProcess is missing from the initial hookMocks.runner mock object in dispatch-from-config.test.ts, while beforeEach unconditionally calls .mockClear() on it. This is a hard runtime failure that will break every test in that file. Additionally, the undocumented ACP bypass is a behavioral concern for plugin authors.
  • src/auto-reply/reply/dispatch-from-config.test.ts — missing mock declaration for runBeforeMessageProcess. src/auto-reply/reply/dispatch-from-config.ts — undocumented ACP bypass for the new hook.

Comments Outside Diff (2)

  1. src/auto-reply/reply/dispatch-from-config.test.ts, line 44-45 (link)

    P2 runBeforeMessageProcess missing from initial mock definition

    hookMocks.runner.runBeforeMessageProcess is referenced in the beforeEach at lines 313–314 (mockClear() and mockResolvedValue()), but it is never declared in the initial hookMocks.runner object (lines 37–45). At runtime, hookMocks.runner.runBeforeMessageProcess will be undefined, so calling .mockClear() on it in beforeEach will throw a TypeError: Cannot read properties of undefined (reading 'mockClear') — breaking every test that exercises this beforeEach block.

    The fix is to add the mock alongside the other runner mocks:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/auto-reply/reply/dispatch-from-config.test.ts
    Line: 44-45
    
    Comment:
    **`runBeforeMessageProcess` missing from initial mock definition**
    
    `hookMocks.runner.runBeforeMessageProcess` is referenced in the `beforeEach` at lines 313–314 (`mockClear()` and `mockResolvedValue()`), but it is never declared in the initial `hookMocks.runner` object (lines 37–45). At runtime, `hookMocks.runner.runBeforeMessageProcess` will be `undefined`, so calling `.mockClear()` on it in `beforeEach` will throw a `TypeError: Cannot read properties of undefined (reading 'mockClear')` — breaking every test that exercises this `beforeEach` block.
    
    The fix is to add the mock alongside the other runner mocks:
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/auto-reply/reply/dispatch-from-config.ts, line 501-531 (link)

    P2 Hook does not fire for ACP-dispatched messages

    The before_message_process check (lines 507–531) is placed after tryDispatchAcpReply (line 484) returns early on line 502. Any message handled by ACP dispatch will bypass the new hook entirely. Plugin developers who expect before_message_process to fire for every inbound message (e.g. for logging, routing, or auth checks) will be surprised when messages routed through ACP silently skip their handler.

    If this is intentional, it should be explicitly documented in the JSDoc for before_message_process (in hooks.ts) and in docs/concepts/agent-loop.md. If it is unintentional, the hook block should be moved to before the ACP dispatch call.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/auto-reply/reply/dispatch-from-config.ts
    Line: 501-531
    
    Comment:
    **Hook does not fire for ACP-dispatched messages**
    
    The `before_message_process` check (lines 507–531) is placed after `tryDispatchAcpReply` (line 484) returns early on line 502. Any message handled by ACP dispatch will bypass the new hook entirely. Plugin developers who expect `before_message_process` to fire for every inbound message (e.g. for logging, routing, or auth checks) will be surprised when messages routed through ACP silently skip their handler.
    
    If this is intentional, it should be explicitly documented in the JSDoc for `before_message_process` (in `hooks.ts`) and in `docs/concepts/agent-loop.md`. If it is unintentional, the hook block should be moved to before the ACP dispatch call.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/auto-reply/reply/dispatch-from-config.test.ts
Line: 44-45

Comment:
**`runBeforeMessageProcess` missing from initial mock definition**

`hookMocks.runner.runBeforeMessageProcess` is referenced in the `beforeEach` at lines 313–314 (`mockClear()` and `mockResolvedValue()`), but it is never declared in the initial `hookMocks.runner` object (lines 37–45). At runtime, `hookMocks.runner.runBeforeMessageProcess` will be `undefined`, so calling `.mockClear()` on it in `beforeEach` will throw a `TypeError: Cannot read properties of undefined (reading 'mockClear')` — breaking every test that exercises this `beforeEach` block.

The fix is to add the mock alongside the other runner mocks:

```suggestion
    runMessageReceived: vi.fn(async () => {}),
    runBeforeMessageProcess: vi.fn(async () => undefined),
  },
```

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

---

This is a comment left during a code review.
Path: src/auto-reply/reply/dispatch-from-config.ts
Line: 501-531

Comment:
**Hook does not fire for ACP-dispatched messages**

The `before_message_process` check (lines 507–531) is placed after `tryDispatchAcpReply` (line 484) returns early on line 502. Any message handled by ACP dispatch will bypass the new hook entirely. Plugin developers who expect `before_message_process` to fire for every inbound message (e.g. for logging, routing, or auth checks) will be surprised when messages routed through ACP silently skip their handler.

If this is intentional, it should be explicitly documented in the JSDoc for `before_message_process` (in `hooks.ts`) and in `docs/concepts/agent-loop.md`. If it is unintentional, the hook block should be moved to before the ACP dispatch call.

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

---

This is a comment left during a code review.
Path: src/plugins/types.ts
Line: 1693

Comment:
**`metadata` field is always `undefined` in practice**

`PluginHookBeforeMessageProcessEvent` declares `metadata?: Record<string, unknown>`, mirroring `PluginHookMessageReceivedEvent`. However, the field is never populated when `runBeforeMessageProcess` is called from `dispatch-from-config.ts``CanonicalInboundMessageHookContext` has no `metadata` field and none is passed at the call site. Plugin developers who try to read `event.metadata` will always receive `undefined`. Either populate the field (e.g. from `ctx.Metadata` if available) or remove it from the type to avoid a misleading API surface.

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

Last reviewed commit: "Merge branch 'opencl..."

Comment thread src/plugins/types.ts
Copy link
Copy Markdown

@WingedDragon WingedDragon left a comment

Choose a reason for hiding this comment

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

🔍 Review: Approve with nits

Scope: Adds before_message_process hook — fires after routing but before AI agent runs. Plugins can return { handled: true } to intercept messages and skip AI processing (first-claim-wins).

Strengths:

  • Well-documented: CHANGELOG, agent-loop docs, and inline comments all updated
  • Test coverage is thorough: handled: true skips AI, undefined runs AI, handled: false runs AI
  • The hook placement (after routing, before agent) is the right interception point
  • First-claim-wins semantics prevent multiple plugins from fighting over message ownership

Nit: The before_message_process hook docs in agent-loop.md say "Return { handled: true } to intercept" — consider also documenting what happens to the message when it's intercepted. Is it silently dropped? Can the hook provide a custom response? This would help plugin authors understand the full behavior.

Verdict: Solid feature addition. Ship it.

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: baa082068b

ℹ️ 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 thread src/auto-reply/reply/dispatch-from-config.ts Outdated
Copy link
Copy Markdown

@WingedDragon WingedDragon left a comment

Choose a reason for hiding this comment

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

✅ Approved

Scope: Adds before_message_process hook that fires after routing but before AI agent runs. Plugins can return { handled: true } to intercept and skip AI processing (first-claim-wins semantics).

Strengths:

  • Well-designed hook lifecycle placement: after routing (session key resolved, channel identified) but before AI (token cost avoided)
  • First-claim-wins semantics — once one plugin handles it, no further processing
  • ACP-dispatched messages correctly excluded (they have their own lifecycle)
  • Test coverage is thorough: handled:true skips AI, undefined/false proceed, event fields correctly passed
  • CHANGELOG and docs updated consistently
  • Type definitions properly extended in src/plugins/types.ts

No concerns. Clean hook addition with good test coverage and documentation. Ship it.

Copy link
Copy Markdown

@WingedDragon WingedDragon left a comment

Choose a reason for hiding this comment

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

🔍 Review: Approve with nits

Scope: New before_message_process hook that fires after routing but before AI processing. Plugins can return { handled: true } to intercept and skip AI entirely (first-claim-wins semantics).

Strengths:

  • Well-designed hook lifecycle: message_received → routing → before_message_process → AI agent → outbound hooks
  • First-claim-wins semantics are correct — prevents multiple plugins from fighting over message ownership
  • handled: true silently drops the message with no automatic reply — the plugin is responsible for responding. This is the right design (avoids double replies).
  • ACP-dispatched messages correctly skip this hook (they have their own session lifecycle)
  • 5 thorough tests: handled=true skips AI, undefined passes through, handled:false passes through, event fields verification, no-registered-handlers skips
  • CHANGELOG and docs updated

Nits:

  1. No test for first-claim-wins behavior — The description mentions "first-claim-wins across registered plugins" but there's no test verifying that when plugin A returns handled: true and plugin B would also want to handle it, plugin B is never called. This is the core semantic guarantee.

  2. No rate limiting or abuse protection — A misconfigured plugin that returns handled: true for all messages would silently drop ALL inbound messages. Consider logging a warning when messages are intercepted, or exposing a counter/metric.

Verdict: Good architectural addition. Ship it after addressing the first-claim-wins test gap.

@xyzphp xyzphp force-pushed the feat/before-message-process-hook branch from 8f9a6fa to 6e5a18d Compare March 19, 2026 13:15
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: 6e5a18db85

ℹ️ 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 thread src/auto-reply/reply/dispatch-from-config.test.ts Outdated
@xyzphp xyzphp force-pushed the feat/before-message-process-hook branch from 94ab012 to 00b6f2b Compare March 19, 2026 13:45
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: 00b6f2b4e5

ℹ️ 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 thread src/auto-reply/reply/dispatch-from-config.ts Outdated
@xyzphp xyzphp force-pushed the feat/before-message-process-hook branch 7 times, most recently from 771c751 to 5db0ac7 Compare March 22, 2026 01:04
jiajz3 added 6 commits March 22, 2026 10:12
…semantics

- Add PluginHookBeforeMessageProcessEvent and PluginHookBeforeMessageProcessResult types
- Add runBeforeMessageProcess() to hook runner; first handler returning handled:true
  short-circuits AI agent processing
- Wire hook into dispatch-from-config after ACP dispatch, before AI agent
- Add unit tests (wired-hooks-before-message-process.test.ts) covering no-handler,
  intercept, pass-through, first-claim-wins, and full event field passing
- Add dispatch-from-config integration tests for intercept and pass-through paths
- Update docs/concepts/agent-loop.md to document the new hook
- Add CHANGELOG entry under Unreleased
- Add missing runBeforeMessageProcess mock to hookMocks.runner in
  dispatch-from-config.test.ts (fixes TypeError in beforeEach)
- Fix conversationId assertion to match actual toPluginMessageContext output
- Remove unpopulated metadata field from PluginHookBeforeMessageProcessEvent
- Document intercepted-message behavior and ACP bypass in agent-loop.md
Move the hook call to fire after fast-abort but before sendPolicy and ACP
dispatch. This ensures plugins can intercept or forward inbound messages
even when the AI agent is disabled via /send off or config-level send
policy deny  the main forwarding/filtering use case for this hook.

Update docs/concepts/agent-loop.md to reflect the new execution order
and remove the incorrect ACP note.

Add integration test: hook fires and can intercept when sendPolicy is deny.
@xyzphp xyzphp force-pushed the feat/before-message-process-hook branch from 5db0ac7 to 531b462 Compare March 22, 2026 02:12
@xyzphp xyzphp requested a review from WingedDragon March 22, 2026 02:20
@xyzphp xyzphp closed this Mar 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants