fix(hooks): suppress system event injection when deliver is false#49234
fix(hooks): suppress system event injection when deliver is false#49234BrennerSpear wants to merge 3 commits into
Conversation
When a hook mapping sets `deliver: false`, the delivery announcement is
correctly suppressed, but a system event ("Hook <name>: <summary>") is
still injected into the main session. This happens because the guard
only checks `!result.delivered` — which is always false when delivery
was never attempted.
Add `value.deliver !== false` to the guard so that hooks with explicit
`deliver: false` complete silently without notifying the main session.
Fixes openclaw#36325
The error path intentionally always surfaces to the main session even when deliver is false — silent hook failures are harder to debug. Add a comment making this asymmetry explicit per review feedback.
Addresses Greptile review feedback on openclaw#36332: - Test that deliver: false mapping suppresses system event injection - Test that errors still surface to main session (intentional asymmetry) - Test that /hooks/agent direct endpoint also respects deliver: false Co-authored-by: cioclawcode <[email protected]>
Greptile SummaryThis PR fixes a bug where hooks configured with The fix is a minimal one-line guard in Three new integration tests are added to
Notes:
Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/gateway/server.hooks.test.ts
Line: 546-547
Comment:
**Fixed-delay wait for negative assertions may be flaky in slow CI**
Both the suppression tests (for mapping and direct `/hooks/agent`) rely on a bare `setTimeout(200)` to allow the `void` async dispatch to complete before asserting `peekSystemEvents(...).length === 0`. With an immediately-resolving mock this is almost always fine, but under heavy CPU contention in CI the microtask queue may not have fully drained by the time the timer fires, which could produce a spurious pass (the system event simply hadn't arrived yet rather than being truly suppressed).
Consider a lightweight poll-until-idle helper, or — since `cronIsolatedRun` is already asserted to have been called — awaiting the completion of the underlying task more deterministically:
```typescript
// One option: poll until the mock has been called, with a shorter timeout
const deadline = Date.now() + 2000;
while (cronIsolatedRun.mock.calls.length === 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 10));
}
```
At minimum, adding a comment explaining why 200 ms is sufficient (mock resolves synchronously in the next microtask) would help future readers understand the intent.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: b0edd79 |
| // Give the async dispatch time to complete. | ||
| await new Promise((resolve) => setTimeout(resolve, 200)); |
There was a problem hiding this comment.
Fixed-delay wait for negative assertions may be flaky in slow CI
Both the suppression tests (for mapping and direct /hooks/agent) rely on a bare setTimeout(200) to allow the void async dispatch to complete before asserting peekSystemEvents(...).length === 0. With an immediately-resolving mock this is almost always fine, but under heavy CPU contention in CI the microtask queue may not have fully drained by the time the timer fires, which could produce a spurious pass (the system event simply hadn't arrived yet rather than being truly suppressed).
Consider a lightweight poll-until-idle helper, or — since cronIsolatedRun is already asserted to have been called — awaiting the completion of the underlying task more deterministically:
// One option: poll until the mock has been called, with a shorter timeout
const deadline = Date.now() + 2000;
while (cronIsolatedRun.mock.calls.length === 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 10));
}At minimum, adding a comment explaining why 200 ms is sufficient (mock resolves synchronously in the next microtask) would help future readers understand the intent.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server.hooks.test.ts
Line: 546-547
Comment:
**Fixed-delay wait for negative assertions may be flaky in slow CI**
Both the suppression tests (for mapping and direct `/hooks/agent`) rely on a bare `setTimeout(200)` to allow the `void` async dispatch to complete before asserting `peekSystemEvents(...).length === 0`. With an immediately-resolving mock this is almost always fine, but under heavy CPU contention in CI the microtask queue may not have fully drained by the time the timer fires, which could produce a spurious pass (the system event simply hadn't arrived yet rather than being truly suppressed).
Consider a lightweight poll-until-idle helper, or — since `cronIsolatedRun` is already asserted to have been called — awaiting the completion of the underlying task more deterministically:
```typescript
// One option: poll until the mock has been called, with a shorter timeout
const deadline = Date.now() + 2000;
while (cronIsolatedRun.mock.calls.length === 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 10));
}
```
At minimum, adding a comment explaining why 200 ms is sufficient (mock resolves synchronously in the next microtask) would help future readers understand the intent.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b0edd79967
ℹ️ 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".
| // delivery *and* main-session notification. Only inject a system | ||
| // event when delivery was requested but did not succeed (e.g. the | ||
| // target channel could not be resolved). | ||
| if (!result.delivered && value.deliver !== false) { |
There was a problem hiding this comment.
Surface run-level errors for deliver-false hooks
This condition suppresses all hook system events when deliver is false, but many failures from runCronIsolatedAgentTurn are returned as status: "error" results (not thrown exceptions). In those cases the catch path never runs, so deliver: false hooks silently drop real execution errors (timeouts, model/config errors, error payload outcomes), making broken automations invisible to operators.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b0edd79967
ℹ️ 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".
| // delivery *and* main-session notification. Only inject a system | ||
| // event when delivery was requested but did not succeed (e.g. the | ||
| // target channel could not be resolved). | ||
| if (!result.delivered && value.deliver !== false) { |
There was a problem hiding this comment.
Surface non-throw hook failures when deliver is false
The new value.deliver !== false guard suppresses all undelivered outcomes for silent hooks, but many real failures from runCronIsolatedAgentTurn are returned as status: "error" results instead of thrown exceptions (for example model/config/payload error paths), so the catch block is never reached. In those cases a deliver: false hook now fails silently with no main-session system event, which breaks observability for background automations even though the code comment says errors should still be surfaced.
Useful? React with 👍 / 👎.
|
@vincentkoc Mind taking a look at this one when you get a chance? It’s a very small hooks fix + tests, and it unblocks a pretty practical Gmail webhook automation case: |
|
Closing this as duplicate or superseded after Codex automated review. Close #49234 as superseded. Current main still has the original one-bit Best possible solution: Close #49234 as superseded and keep the remaining hooks work on #55761, where the fix should separate main-session announcement policy from delivery state, keep successful What I checked:
So I’m closing this here and keeping the remaining discussion on the canonical linked item. Codex Review notes: model gpt-5.5, reasoning high; reviewed against 54f8e4145e43. |
Summary / What changed
Builds on the fix from #36332 by @cioclawcode, rebased onto current main with test coverage added.
Fix (commits 1-2 by @cioclawcode)
src/gateway/server/hooks.ts: GuardenqueueSystemEventwithvalue.deliver !== falseso hooks withdeliver: falsecomplete silently — no delivery, no system event injected into the main session.Tests (commit 3)
Addresses the Greptile 3/5 review on the original PR:
suppresses system event when mapping sets deliver: false— verifies no system event is enqueued when a mapping completes successfully withdeliver: falsestill surfaces errors to main session even when deliver: false— confirms the intentional error-path asymmetry is working as designeddirect /hooks/agent with deliver: false suppresses system event— covers the/hooks/agentdirect endpoint path (not just mappings)Why
When
deliver: falseis set on a hook mapping (e.g. for silent email triage), the hook run summary was still injected as a system event into the main session, which then got announced to the last active channel (Telegram, Discord, etc.). This madedeliver: falseineffective for truly silent background automations.Issue
Fixes #36325
Supersedes #36332
Co-authored-by: @cioclawcode (original fix + docs commits preserved with authorship)