fix: wire 9 unwired plugin hooks to core code (openclaw#14882) thanks…#1
fix: wire 9 unwired plugin hooks to core code (openclaw#14882) thanks…#1PeterTheSavage merged 9 commits intoPeterTheSavage:mainfrom
Conversation
Verified: - GitHub CI checks green (non-skipped) Co-authored-by: shtse8 <8020099+shtse8@users.noreply.github.com>
The heartbeat runner was incorrectly triggering CRON_EVENT_PROMPT whenever ANY system events existed during a cron heartbeat, even if those events were unrelated (e.g., HEARTBEAT_OK acks, exec completions). This caused phantom 'scheduled reminder' notifications with no actual reminder content. Fix: Only treat as cron event if pending events contain actual cron-related messages, excluding standard heartbeat acks and exec completion messages. Fixes #13317
- Add resetSystemEventsForTest() in beforeEach/afterEach - Fix hardcoded status assertions (use toBeDefined + conditional checks) - Prevents cross-test pollution of global system event queue Addresses Greptile feedback on PR #15059
Combines two complementary fixes for ghost reminder bug: 1. Filter HEARTBEAT_OK/exec messages (previous commit) 2. Embed actual event content in prompt (this commit) Instead of static 'shown above' message, dynamically build prompt with actual reminder text. Ensures model sees event content directly. Credit: Approach inspired by @nyx-rymera's analysis in #13317 Fixes #13317
Reviewer's GuideWires previously-unconnected plugin hooks into core heartbeat, messaging, session, compaction, and gateway flows, adds cron system-event filtering to avoid ghost reminders, and introduces focused tests to validate hook wiring and new cron behavior. Sequence diagram for outbound message delivery with message_sending and message_sent hookssequenceDiagram
participant OutboundDelivery
participant HookRunner
participant ChannelHandler
OutboundDelivery->>HookRunner: getGlobalHookRunner
Note over OutboundDelivery,HookRunner: HookRunner may be undefined if no plugins
loop For each normalizedPayload
OutboundDelivery->>OutboundDelivery: Build payloadSummary
alt message_sending hooks registered
OutboundDelivery->>HookRunner: hasHooks(message_sending)
HookRunner-->>OutboundDelivery: true
OutboundDelivery->>HookRunner: runMessageSending(to, content, metadata, context)
HookRunner-->>OutboundDelivery: sendingResult
alt sendingResult.cancel
OutboundDelivery->>OutboundDelivery: Skip delivery and continue loop
else sendingResult.content provided
OutboundDelivery->>OutboundDelivery: Override payload.text with sendingResult.content
end
else no message_sending hooks
OutboundDelivery->>HookRunner: hasHooks(message_sending)
HookRunner-->>OutboundDelivery: false
end
OutboundDelivery->>OutboundDelivery: params.onPayload(payloadSummary)?
alt handler.sendPayload available
OutboundDelivery->>ChannelHandler: sendPayload(effectivePayload)
ChannelHandler-->>OutboundDelivery: result
else send text/media via handler
OutboundDelivery->>ChannelHandler: sendText / sendMedia
ChannelHandler-->>OutboundDelivery: result
end
alt send succeeded
OutboundDelivery->>OutboundDelivery: Push result
alt message_sent hooks registered
OutboundDelivery->>HookRunner: hasHooks(message_sent)
HookRunner-->>OutboundDelivery: true
OutboundDelivery-->>HookRunner: runMessageSent({success: true}, context) fire-and-forget
else no message_sent hooks
OutboundDelivery->>HookRunner: hasHooks(message_sent)
HookRunner-->>OutboundDelivery: false
end
else send throws error
OutboundDelivery->>OutboundDelivery: Catch err
alt message_sent hooks registered
OutboundDelivery->>HookRunner: hasHooks(message_sent)
HookRunner-->>OutboundDelivery: true
OutboundDelivery-->>HookRunner: runMessageSent({success: false, error}, context) fire-and-forget
else no message_sent hooks
OutboundDelivery->>HookRunner: hasHooks(message_sent)
HookRunner-->>OutboundDelivery: false
end
alt bestEffort is false
OutboundDelivery->>OutboundDelivery: Rethrow error
else
OutboundDelivery->>OutboundDelivery: Continue loop
end
end
end
Sequence diagram for session initialization with session_start and session_end hookssequenceDiagram
participant SessionInit
participant HookRunner
SessionInit->>HookRunner: getGlobalHookRunner
alt isNewSession is true and hookRunner exists
alt previousSessionEntry.sessionId differs from new sessionId
SessionInit->>HookRunner: hasHooks(session_end)
alt session_end hooks registered
HookRunner-->>SessionInit: true
SessionInit-->>HookRunner: runSessionEnd({sessionId, messageCount: 0}, {sessionId, agentId}) fire-and-forget
else no session_end hooks
HookRunner-->>SessionInit: false
end
end
SessionInit->>HookRunner: hasHooks(session_start)
alt session_start hooks registered
HookRunner-->>SessionInit: true
SessionInit-->>HookRunner: runSessionStart({sessionId, resumedFrom}, {sessionId, agentId}) fire-and-forget
else no session_start hooks
HookRunner-->>SessionInit: false
end
else not a new session or no hookRunner
SessionInit->>SessionInit: Skip session hooks
end
SessionInit-->>SessionInit: Return sessionCtx and sessionEntry
Sequence diagram for tool execution with after_tool_call hooksequenceDiagram
participant AgentRuntime
participant ToolHandlers
participant HookRunner
Note over AgentRuntime,ToolHandlers: handleToolExecutionStart
AgentRuntime->>ToolHandlers: handleToolExecutionStart(ctx, event)
ToolHandlers->>ToolHandlers: Derive toolName and args
ToolHandlers->>ToolHandlers: toolStartData.set(toolCallId, {startTime, args})
Note over AgentRuntime,ToolHandlers: Tool executes asynchronously
Note over AgentRuntime,ToolHandlers: handleToolExecutionEnd
AgentRuntime->>ToolHandlers: handleToolExecutionEnd(ctx, event)
ToolHandlers->>ToolHandlers: Lookup toolCallId metadata
ToolHandlers->>ToolHandlers: Emit summaries/outputs as needed
ToolHandlers->>HookRunner: getGlobalHookRunner
alt after_tool_call hooks registered
ToolHandlers->>HookRunner: hasHooks(after_tool_call)
HookRunner-->>ToolHandlers: true
ToolHandlers->>ToolHandlers: startData = toolStartData.get(toolCallId)
ToolHandlers->>ToolHandlers: durationMs = now - startData.startTime
ToolHandlers->>ToolHandlers: params = startData.args as object
ToolHandlers->>ToolHandlers: error = isError ? extractToolErrorMessage(result) : undefined
ToolHandlers->>ToolHandlers: toolStartData.delete(toolCallId)
ToolHandlers-->>HookRunner: runAfterToolCall({toolName, params, result, error, durationMs}, {toolName, agentId, sessionKey}) fire-and-forget
else no after_tool_call hooks
ToolHandlers->>HookRunner: hasHooks(after_tool_call)
HookRunner-->>ToolHandlers: false
ToolHandlers->>ToolHandlers: toolStartData.delete(toolCallId)
end
Sequence diagram for auto-compaction with before_compaction and after_compaction hookssequenceDiagram
participant CompactionLogic
participant HookRunner
Note over CompactionLogic,HookRunner: handleAutoCompactionStart
CompactionLogic->>HookRunner: getGlobalHookRunner
alt before_compaction hooks registered
CompactionLogic->>HookRunner: hasHooks(before_compaction)
HookRunner-->>CompactionLogic: true
CompactionLogic-->>HookRunner: runBeforeCompaction({messageCount}, {}) fire-and-forget
else no before_compaction hooks
CompactionLogic->>HookRunner: hasHooks(before_compaction)
HookRunner-->>CompactionLogic: false
end
CompactionLogic->>CompactionLogic: Perform compaction work
Note over CompactionLogic,HookRunner: handleAutoCompactionEnd
alt willRetry is false
CompactionLogic->>HookRunner: getGlobalHookRunner
alt after_compaction hooks registered
CompactionLogic->>HookRunner: hasHooks(after_compaction)
HookRunner-->>CompactionLogic: true
CompactionLogic->>CompactionLogic: compactedCount = getCompactionCount()
CompactionLogic-->>HookRunner: runAfterCompaction({messageCount, compactedCount}, {}) fire-and-forget
else no after_compaction hooks
CompactionLogic->>HookRunner: hasHooks(after_compaction)
HookRunner-->>CompactionLogic: false
end
else willRetry is true
CompactionLogic->>CompactionLogic: Skip after_compaction hook
end
Sequence diagram for gateway lifecycle with gateway_start and gateway_stop hookssequenceDiagram
participant GatewayBootstrap
participant HookRunner
Note over GatewayBootstrap,HookRunner: startGatewayServer
GatewayBootstrap->>HookRunner: getGlobalHookRunner
alt gateway_start hooks registered
GatewayBootstrap->>HookRunner: hasHooks(gateway_start)
HookRunner-->>GatewayBootstrap: true
GatewayBootstrap-->>HookRunner: runGatewayStart({port}, {port}) fire-and-forget
else no gateway_start hooks
GatewayBootstrap->>HookRunner: hasHooks(gateway_start)
HookRunner-->>GatewayBootstrap: false
end
GatewayBootstrap->>GatewayBootstrap: Start HTTP server and subsystems
Note over GatewayBootstrap,HookRunner: close handler
GatewayBootstrap->>HookRunner: getGlobalHookRunner
alt gateway_stop hooks registered
GatewayBootstrap->>HookRunner: hasHooks(gateway_stop)
HookRunner-->>GatewayBootstrap: true
GatewayBootstrap->>HookRunner: runGatewayStop({reason}, {port})
HookRunner-->>GatewayBootstrap: completion or error
GatewayBootstrap->>GatewayBootstrap: Log warning on hook failure
else no gateway_stop hooks
GatewayBootstrap->>HookRunner: hasHooks(gateway_stop)
HookRunner-->>GatewayBootstrap: false
end
GatewayBootstrap->>GatewayBootstrap: Stop diagnostics and server
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Summary of ChangesHello @PeterTheSavage, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly enhances the system's extensibility by wiring nine previously unwired plugin hooks into various core functionalities. These integrations allow plugins to intercept and react to critical events such as agent lifecycle, tool execution, session management, gateway startup/shutdown, and message delivery, thereby enabling more powerful and flexible customizations and observability for external modules. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- The global
toolStartDatamap inpi-embedded-subscribe.handlers.tools.tsis only cleaned up inhandleToolExecutionEnd, so if a tool call never emits an*_endevent (e.g., aborted runs) entries will accumulate; consider adding a timeout/cleanup or a defensive delete onhandleToolExecutionStartto avoid leaks across long-lived processes. - Hook failures are silently swallowed in several places (
message_sending/message_sentindeliverOutboundPayloads, and session hooks ininitSessionState) while similar hooks elsewhere log warnings; for easier debugging and consistent behavior you may want to at least log a warning in thesecatchblocks.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The global `toolStartData` map in `pi-embedded-subscribe.handlers.tools.ts` is only cleaned up in `handleToolExecutionEnd`, so if a tool call never emits an `*_end` event (e.g., aborted runs) entries will accumulate; consider adding a timeout/cleanup or a defensive delete on `handleToolExecutionStart` to avoid leaks across long-lived processes.
- Hook failures are silently swallowed in several places (`message_sending` / `message_sent` in `deliverOutboundPayloads`, and session hooks in `initSessionState`) while similar hooks elsewhere log warnings; for easier debugging and consistent behavior you may want to at least log a warning in these `catch` blocks.
## Individual Comments
### Comment 1
<location> `src/infra/outbound/deliver.ts:379-367` </location>
<code_context>
params.onPayload?.(payloadSummary);
- if (handler.sendPayload && payload.channelData) {
- results.push(await handler.sendPayload(payload));
+ if (handler.sendPayload && effectivePayload.channelData) {
+ results.push(await handler.sendPayload(effectivePayload));
continue;
}
if (payloadSummary.mediaUrls.length === 0) {
</code_context>
<issue_to_address>
**issue (bug_risk):** message_sent hook is never called for handlers using sendPayload due to early continue
In the `handler.sendPayload && effectivePayload.channelData` branch, the function `continue`s before invoking the `message_sent` hook, so plugins relying on that hook won’t see messages sent via `sendPayload`, unlike the media/text paths below. To keep hook behavior consistent across all successful send paths, move the `message_sent` invocation before the `continue` or refactor so all branches go through a shared post-send section that triggers it.
</issue_to_address>
### Comment 2
<location> `src/infra/heartbeat-runner.ts:119` </location>
<code_context>
+
+const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase();
+
+// Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events.
+function isHeartbeatAckEvent(evt: string): boolean {
+ const trimmed = evt.trim();
</code_context>
<issue_to_address>
**issue (complexity):** Consider replacing the multiple small predicate helpers with a single event classifier plus an analyzer so the heartbeat flow reads as classify → analyze → choose prompt.
You can reduce the mental overhead by collapsing the tiny predicates into a single classifier + analyzer, and drive `runHeartbeatOnce` from that. This keeps behavior while making the flow “read” like the domain: classify events → analyze → choose prompt.
### 1. Collapse predicates into a classifier
Instead of `isHeartbeatAckEvent`, `isHeartbeatNoiseEvent`, `isExecCompletionEvent`, and `isCronSystemEvent` all composing each other, fold them into one `classifySystemEvent` and a small analyzer. You can keep `HEARTBEAT_OK_PREFIX` and the regex logic, just move it inside.
```ts
type SystemEventKind = "cron" | "exec-completion" | "heartbeat-noise" | "other";
function classifySystemEvent(evt: string): SystemEventKind {
const trimmed = evt.trim();
if (!trimmed) return "other";
const lower = trimmed.toLowerCase();
// exec completion detection (previous isExecCompletionEvent)
if (lower.includes("exec finished")) {
return "exec-completion";
}
// heartbeat noise detection (previous isHeartbeatAckEvent + isHeartbeatNoiseEvent)
const isHeartbeatAck = (() => {
if (!lower.startsWith(HEARTBEAT_OK_PREFIX)) return false;
const suffix = lower.slice(HEARTBEAT_OK_PREFIX.length);
if (!suffix.length) return true;
return !/[a-z0-9_]/.test(suffix[0]);
})();
if (
isHeartbeatAck ||
lower.includes("heartbeat poll") ||
lower.includes("heartbeat wake")
) {
return "heartbeat-noise";
}
// Anything else is treated as cron content
return "cron";
}
// Preserve the existing export but simplify it to use the classifier.
export function isCronSystemEvent(evt: string) {
return classifySystemEvent(evt) === "cron";
}
```
This removes layered negations and consolidates “what kind of system event is this?” into one place.
### 2. Centralize analysis of pending system events
You can keep `buildCronEventPrompt` as‑is, but co‑locate it with a small analyzer so `runHeartbeatOnce` doesn’t need to know all the details:
```ts
function analyzeSystemEvents(
reason: string | undefined,
pendingEvents: string[]
): { cronEvents: string[]; hasExecCompletion: boolean; hasCronEvents: boolean } {
const isExecEvent = reason === "exec-event";
const isCronEvent = Boolean(reason?.startsWith("cron:"));
let cronEvents: string[] = [];
let hasExecCompletion = false;
for (const evt of pendingEvents) {
const kind = classifySystemEvent(evt);
if (kind === "exec-completion") {
hasExecCompletion = true;
} else if (kind === "cron") {
cronEvents.push(evt);
}
}
const hasCronEvents = isCronEvent && cronEvents.length > 0;
return { cronEvents, hasExecCompletion, hasCronEvents };
}
```
### 3. Simplify `runHeartbeatOnce` call site
Now `runHeartbeatOnce` can be simpler and more self‑descriptive, without multiple low‑level helpers:
```ts
const isExecEvent = opts.reason === "exec-event";
const isCronEvent = Boolean(opts.reason?.startsWith("cron:"));
const pendingEvents = isExecEvent || isCronEvent ? peekSystemEvents(sessionKey) : [];
const { cronEvents, hasExecCompletion, hasCronEvents } = analyzeSystemEvents(
opts.reason,
pendingEvents
);
const prompt = hasExecCompletion
? EXEC_EVENT_PROMPT
: hasCronEvents
? buildCronEventPrompt(cronEvents)
: resolveHeartbeatPrompt(cfg, heartbeat);
const ctx = {
Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),
From: sender,
To: sender,
Provider: hasExecCompletion ? "exec-event" : hasCronEvents ? "cron-event" : "heartbeat",
SessionKey: sessionKey,
};
```
Behavior is preserved:
- Exec completion detection still uses the same `"exec finished"` logic.
- Heartbeat noise (ack, poll, wake) is still filtered out with the same normalization and regex.
- Cron events are now the explicit `"cron"` classification, not “not noise and not exec”.
But the complexity at the top level is reduced to “classify → analyze → prompt”, and the string‑level nuance is contained in one place.
</issue_to_address>
### Comment 3
<location> `src/infra/outbound/deliver.ts:342` </location>
<code_context>
return normalized ? [normalized] : [];
});
+ const hookRunner = getGlobalHookRunner();
for (const payload of normalizedPayloads) {
const payloadSummary: NormalizedOutboundPayload = {
text: payload.text ?? "",
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the hook-handling logic into small helper functions so the delivery loop focuses on core behavior and is easier to read.
You can keep the new behavior but reduce cognitive load in the loop by pushing the hook logic into small helpers and consolidating the duplicated `message_sent` calls.
### 1. Extract `message_sending` hook into a helper
Encapsulate the try/catch, cancel semantics, and mutation of payload into a dedicated function:
```ts
type MessageSendingInput = {
to: string;
channel: string;
accountId?: string | null;
payload: NormalizedOutboundPayloadInputType; // whatever `payload` is
summary: NormalizedOutboundPayload;
};
async function runMessageSendingHook(
hookRunner: ReturnType<typeof getGlobalHookRunner> | undefined,
{ to, channel, accountId, payload, summary }: MessageSendingInput,
): Promise<{ canceled: boolean; effectivePayload: typeof payload }> {
if (!hookRunner?.hasHooks("message_sending")) {
return { canceled: false, effectivePayload: payload };
}
try {
const sendingResult = await hookRunner.runMessageSending(
{
to,
content: summary.text,
metadata: { channel, accountId, mediaUrls: summary.mediaUrls },
},
{ channelId: channel, accountId: accountId ?? undefined },
);
if (sendingResult?.cancel) {
return { canceled: true, effectivePayload: payload };
}
if (sendingResult?.content != null) {
summary.text = sendingResult.content;
return { canceled: false, effectivePayload: { ...payload, text: sendingResult.content } };
}
return { canceled: false, effectivePayload: payload };
} catch {
// Don't block delivery on hook failure
return { canceled: false, effectivePayload: payload };
}
}
```
Then the loop becomes easier to scan:
```ts
for (const payload of normalizedPayloads) {
const payloadSummary: NormalizedOutboundPayload = { /* ... */ };
try {
throwIfAborted(abortSignal);
const { canceled, effectivePayload } = await runMessageSendingHook(hookRunner, {
to,
channel,
accountId,
payload,
summary: payloadSummary,
});
if (canceled) continue;
params.onPayload?.(payloadSummary);
if (handler.sendPayload && effectivePayload.channelData) {
results.push(await handler.sendPayload(effectivePayload));
continue;
}
// rest of delivery logic unchanged...
```
### 2. Consolidate `message_sent` success/failure into a helper
The success/failure paths are nearly identical; a helper makes the intent explicit and removes duplication:
```ts
function fireMessageSentHook(
hookRunner: ReturnType<typeof getGlobalHookRunner> | undefined,
{
to,
channel,
accountId,
content,
success,
error,
}: {
to: string;
channel: string;
accountId?: string | null;
content: string;
success: boolean;
error?: unknown;
},
): void {
if (!hookRunner?.hasHooks("message_sent")) return;
const errorMessage =
!success || error != null
? error instanceof Error
? error.message
: String(error)
: undefined;
void hookRunner
.runMessageSent(
{ to, content, success, error: errorMessage },
{ channelId: channel, accountId: accountId ?? undefined },
)
.catch(() => {});
}
```
Then in the loop:
```ts
// on success
fireMessageSentHook(hookRunner, {
to,
channel,
accountId,
content: payloadSummary.text,
success: true,
});
// in catch
} catch (err) {
fireMessageSentHook(hookRunner, {
to,
channel,
accountId,
content: payloadSummary.text,
success: false,
error: err,
});
if (!params.bestEffort) {
throw err;
}
params.onError?.(err, payloadSummary);
}
```
This keeps all existing behavior (including fire‑and‑forget semantics and error swallowing for hooks) but makes the core loop mostly about delivery, with hook behavior clearly named and localized.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| }, | ||
| ); | ||
| if (sendingResult?.cancel) { | ||
| continue; |
There was a problem hiding this comment.
issue (bug_risk): message_sent hook is never called for handlers using sendPayload due to early continue
In the handler.sendPayload && effectivePayload.channelData branch, the function continues before invoking the message_sent hook, so plugins relying on that hook won’t see messages sent via sendPayload, unlike the media/text paths below. To keep hook behavior consistent across all successful send paths, move the message_sent invocation before the continue or refactor so all branches go through a shared post-send section that triggers it.
|
|
||
| const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase(); | ||
|
|
||
| // Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events. |
There was a problem hiding this comment.
issue (complexity): Consider replacing the multiple small predicate helpers with a single event classifier plus an analyzer so the heartbeat flow reads as classify → analyze → choose prompt.
You can reduce the mental overhead by collapsing the tiny predicates into a single classifier + analyzer, and drive runHeartbeatOnce from that. This keeps behavior while making the flow “read” like the domain: classify events → analyze → choose prompt.
1. Collapse predicates into a classifier
Instead of isHeartbeatAckEvent, isHeartbeatNoiseEvent, isExecCompletionEvent, and isCronSystemEvent all composing each other, fold them into one classifySystemEvent and a small analyzer. You can keep HEARTBEAT_OK_PREFIX and the regex logic, just move it inside.
type SystemEventKind = "cron" | "exec-completion" | "heartbeat-noise" | "other";
function classifySystemEvent(evt: string): SystemEventKind {
const trimmed = evt.trim();
if (!trimmed) return "other";
const lower = trimmed.toLowerCase();
// exec completion detection (previous isExecCompletionEvent)
if (lower.includes("exec finished")) {
return "exec-completion";
}
// heartbeat noise detection (previous isHeartbeatAckEvent + isHeartbeatNoiseEvent)
const isHeartbeatAck = (() => {
if (!lower.startsWith(HEARTBEAT_OK_PREFIX)) return false;
const suffix = lower.slice(HEARTBEAT_OK_PREFIX.length);
if (!suffix.length) return true;
return !/[a-z0-9_]/.test(suffix[0]);
})();
if (
isHeartbeatAck ||
lower.includes("heartbeat poll") ||
lower.includes("heartbeat wake")
) {
return "heartbeat-noise";
}
// Anything else is treated as cron content
return "cron";
}
// Preserve the existing export but simplify it to use the classifier.
export function isCronSystemEvent(evt: string) {
return classifySystemEvent(evt) === "cron";
}This removes layered negations and consolidates “what kind of system event is this?” into one place.
2. Centralize analysis of pending system events
You can keep buildCronEventPrompt as‑is, but co‑locate it with a small analyzer so runHeartbeatOnce doesn’t need to know all the details:
function analyzeSystemEvents(
reason: string | undefined,
pendingEvents: string[]
): { cronEvents: string[]; hasExecCompletion: boolean; hasCronEvents: boolean } {
const isExecEvent = reason === "exec-event";
const isCronEvent = Boolean(reason?.startsWith("cron:"));
let cronEvents: string[] = [];
let hasExecCompletion = false;
for (const evt of pendingEvents) {
const kind = classifySystemEvent(evt);
if (kind === "exec-completion") {
hasExecCompletion = true;
} else if (kind === "cron") {
cronEvents.push(evt);
}
}
const hasCronEvents = isCronEvent && cronEvents.length > 0;
return { cronEvents, hasExecCompletion, hasCronEvents };
}3. Simplify runHeartbeatOnce call site
Now runHeartbeatOnce can be simpler and more self‑descriptive, without multiple low‑level helpers:
const isExecEvent = opts.reason === "exec-event";
const isCronEvent = Boolean(opts.reason?.startsWith("cron:"));
const pendingEvents = isExecEvent || isCronEvent ? peekSystemEvents(sessionKey) : [];
const { cronEvents, hasExecCompletion, hasCronEvents } = analyzeSystemEvents(
opts.reason,
pendingEvents
);
const prompt = hasExecCompletion
? EXEC_EVENT_PROMPT
: hasCronEvents
? buildCronEventPrompt(cronEvents)
: resolveHeartbeatPrompt(cfg, heartbeat);
const ctx = {
Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),
From: sender,
To: sender,
Provider: hasExecCompletion ? "exec-event" : hasCronEvents ? "cron-event" : "heartbeat",
SessionKey: sessionKey,
};Behavior is preserved:
- Exec completion detection still uses the same
"exec finished"logic. - Heartbeat noise (ack, poll, wake) is still filtered out with the same normalization and regex.
- Cron events are now the explicit
"cron"classification, not “not noise and not exec”.
But the complexity at the top level is reduced to “classify → analyze → prompt”, and the string‑level nuance is contained in one place.
| return normalized ? [normalized] : []; | ||
| }); | ||
| const hookRunner = getGlobalHookRunner(); | ||
| for (const payload of normalizedPayloads) { |
There was a problem hiding this comment.
issue (complexity): Consider extracting the hook-handling logic into small helper functions so the delivery loop focuses on core behavior and is easier to read.
You can keep the new behavior but reduce cognitive load in the loop by pushing the hook logic into small helpers and consolidating the duplicated message_sent calls.
1. Extract message_sending hook into a helper
Encapsulate the try/catch, cancel semantics, and mutation of payload into a dedicated function:
type MessageSendingInput = {
to: string;
channel: string;
accountId?: string | null;
payload: NormalizedOutboundPayloadInputType; // whatever `payload` is
summary: NormalizedOutboundPayload;
};
async function runMessageSendingHook(
hookRunner: ReturnType<typeof getGlobalHookRunner> | undefined,
{ to, channel, accountId, payload, summary }: MessageSendingInput,
): Promise<{ canceled: boolean; effectivePayload: typeof payload }> {
if (!hookRunner?.hasHooks("message_sending")) {
return { canceled: false, effectivePayload: payload };
}
try {
const sendingResult = await hookRunner.runMessageSending(
{
to,
content: summary.text,
metadata: { channel, accountId, mediaUrls: summary.mediaUrls },
},
{ channelId: channel, accountId: accountId ?? undefined },
);
if (sendingResult?.cancel) {
return { canceled: true, effectivePayload: payload };
}
if (sendingResult?.content != null) {
summary.text = sendingResult.content;
return { canceled: false, effectivePayload: { ...payload, text: sendingResult.content } };
}
return { canceled: false, effectivePayload: payload };
} catch {
// Don't block delivery on hook failure
return { canceled: false, effectivePayload: payload };
}
}Then the loop becomes easier to scan:
for (const payload of normalizedPayloads) {
const payloadSummary: NormalizedOutboundPayload = { /* ... */ };
try {
throwIfAborted(abortSignal);
const { canceled, effectivePayload } = await runMessageSendingHook(hookRunner, {
to,
channel,
accountId,
payload,
summary: payloadSummary,
});
if (canceled) continue;
params.onPayload?.(payloadSummary);
if (handler.sendPayload && effectivePayload.channelData) {
results.push(await handler.sendPayload(effectivePayload));
continue;
}
// rest of delivery logic unchanged...2. Consolidate message_sent success/failure into a helper
The success/failure paths are nearly identical; a helper makes the intent explicit and removes duplication:
function fireMessageSentHook(
hookRunner: ReturnType<typeof getGlobalHookRunner> | undefined,
{
to,
channel,
accountId,
content,
success,
error,
}: {
to: string;
channel: string;
accountId?: string | null;
content: string;
success: boolean;
error?: unknown;
},
): void {
if (!hookRunner?.hasHooks("message_sent")) return;
const errorMessage =
!success || error != null
? error instanceof Error
? error.message
: String(error)
: undefined;
void hookRunner
.runMessageSent(
{ to, content, success, error: errorMessage },
{ channelId: channel, accountId: accountId ?? undefined },
)
.catch(() => {});
}Then in the loop:
// on success
fireMessageSentHook(hookRunner, {
to,
channel,
accountId,
content: payloadSummary.text,
success: true,
});
// in catch
} catch (err) {
fireMessageSentHook(hookRunner, {
to,
channel,
accountId,
content: payloadSummary.text,
success: false,
error: err,
});
if (!params.bestEffort) {
throw err;
}
params.onError?.(err, payloadSummary);
}This keeps all existing behavior (including fire‑and‑forget semantics and error swallowing for hooks) but makes the core loop mostly about delivery, with hook behavior clearly named and localized.
There was a problem hiding this comment.
Code Review
This pull request effectively wires up several previously unused plugin hooks across different parts of the application, such as message delivery, session lifecycle, and tool execution. The changes are well-structured and include corresponding tests to validate the new integrations.
My review focuses on a few areas for improvement:
- A potential concurrency issue with module-level state for tool execution tracking.
- Opportunities to improve error handling for hooks by adding logging instead of silently swallowing errors.
- A minor code deduplication suggestion.
Overall, this is a solid contribution that significantly enhances the plugin system's capabilities.
| import { normalizeToolName } from "./tool-policy.js"; | ||
|
|
||
| /** Track tool execution start times and args for after_tool_call hook */ | ||
| const toolStartData = new Map<string, { startTime: number; args: unknown }>(); |
There was a problem hiding this comment.
The toolStartData map is a module-level variable. This can lead to race conditions and incorrect behavior if multiple agent runs are processed concurrently within the same process, as they would share this state. For example, two concurrent tool calls with the same toolCallId from different runs would conflict.
To ensure proper isolation, this state should be moved into the EmbeddedPiSubscribeState and accessed through the ctx object (e.g., ctx.state.toolStartData). This will scope the tool execution tracking to the specific agent subscription context.
| const hookRunner = getGlobalHookRunner(); | ||
| if (hookRunner?.hasHooks("after_tool_call")) { | ||
| const startData = toolStartData.get(toolCallId); | ||
| toolStartData.delete(toolCallId); | ||
| const durationMs = startData?.startTime != null ? Date.now() - startData.startTime : undefined; | ||
| const toolArgs = startData?.args; | ||
| void hookRunner | ||
| .runAfterToolCall( | ||
| { | ||
| toolName, | ||
| params: (toolArgs && typeof toolArgs === "object" ? toolArgs : {}) as Record< | ||
| string, | ||
| unknown | ||
| >, | ||
| result: sanitizedResult, | ||
| error: isToolError ? extractToolErrorMessage(sanitizedResult) : undefined, | ||
| durationMs, | ||
| }, | ||
| { | ||
| toolName, | ||
| agentId: undefined, | ||
| sessionKey: undefined, | ||
| }, | ||
| ) | ||
| .catch((err) => { | ||
| ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`); | ||
| }); | ||
| } else { | ||
| toolStartData.delete(toolCallId); | ||
| } |
There was a problem hiding this comment.
There's a small code duplication here. The call to toolStartData.delete(toolCallId) is present in both the if and else blocks. You can simplify this by moving the deletion out of the conditional, since it should happen regardless of whether hooks are registered.
const hookRunner = getGlobalHookRunner();
const startData = toolStartData.get(toolCallId);
toolStartData.delete(toolCallId);
if (hookRunner?.hasHooks("after_tool_call")) {
const durationMs = startData?.startTime != null ? Date.now() - startData.startTime : undefined;
const toolArgs = startData?.args;
void hookRunner
.runAfterToolCall(
{
toolName,
params: (toolArgs && typeof toolArgs === "object" ? toolArgs : {}) as Record<
string,
unknown
>,
result: sanitizedResult,
error: isToolError ? extractToolErrorMessage(sanitizedResult) : undefined,
durationMs,
},
{
toolName,
agentId: undefined,
sessionKey: undefined,
},
)
.catch((err) => {
ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
});
}| // If replacing an existing session, fire session_end for the old one | ||
| if (previousSessionEntry?.sessionId && previousSessionEntry.sessionId !== effectiveSessionId) { | ||
| if (hookRunner.hasHooks("session_end")) { | ||
| void hookRunner | ||
| .runSessionEnd( | ||
| { | ||
| sessionId: previousSessionEntry.sessionId, | ||
| messageCount: 0, | ||
| }, | ||
| { | ||
| sessionId: previousSessionEntry.sessionId, | ||
| agentId: resolveSessionAgentId({ sessionKey, config: cfg }), | ||
| }, | ||
| ) | ||
| .catch(() => {}); | ||
| } | ||
| } | ||
|
|
||
| // Fire session_start for the new session | ||
| if (hookRunner.hasHooks("session_start")) { | ||
| void hookRunner | ||
| .runSessionStart( | ||
| { | ||
| sessionId: effectiveSessionId, | ||
| resumedFrom: previousSessionEntry?.sessionId, | ||
| }, | ||
| { | ||
| sessionId: effectiveSessionId, | ||
| agentId: resolveSessionAgentId({ sessionKey, config: cfg }), | ||
| }, | ||
| ) | ||
| .catch(() => {}); | ||
| } | ||
| } |
There was a problem hiding this comment.
The .catch(() => {}) blocks for runSessionEnd and runSessionStart hooks silently swallow any errors from plugins. While it's important not to block session initialization on hook failures, logging these errors is crucial for debugging plugins. Please consider adding a console.warn to log the error, similar to how it's handled in other parts of the codebase.
// If replacing an existing session, fire session_end for the old one
if (previousSessionEntry?.sessionId && previousSessionEntry.sessionId !== effectiveSessionId) {
if (hookRunner.hasHooks("session_end")) {
void hookRunner
.runSessionEnd(
{
sessionId: previousSessionEntry.sessionId,
messageCount: 0,
},
{
sessionId: previousSessionEntry.sessionId,
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
},
)
.catch((err) => {
console.warn(`session_end hook failed: ${String(err)}`);
});
}
}
// Fire session_start for the new session
if (hookRunner.hasHooks("session_start")) {
void hookRunner
.runSessionStart(
{
sessionId: effectiveSessionId,
resumedFrom: previousSessionEntry?.sessionId,
},
{
sessionId: effectiveSessionId,
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
},
)
.catch((err) => {
console.warn(`session_start hook failed: ${String(err)}`);
});
}| try { | ||
| const sendingResult = await hookRunner.runMessageSending( | ||
| { | ||
| to, | ||
| content: payloadSummary.text, | ||
| metadata: { channel, accountId, mediaUrls: payloadSummary.mediaUrls }, | ||
| }, | ||
| { | ||
| channelId: channel, | ||
| accountId: accountId ?? undefined, | ||
| }, | ||
| ); | ||
| if (sendingResult?.cancel) { | ||
| continue; | ||
| } | ||
| if (sendingResult?.content != null) { | ||
| effectivePayload = { ...payload, text: sendingResult.content }; | ||
| payloadSummary.text = sendingResult.content; | ||
| } | ||
| } catch { | ||
| // Don't block delivery on hook failure | ||
| } |
There was a problem hiding this comment.
The catch block for the message_sending hook is empty, which silently swallows errors. While not blocking delivery is important, logging these errors via console.warn is crucial for debugging plugins.
try {
const sendingResult = await hookRunner.runMessageSending(
{
to,
content: payloadSummary.text,
metadata: { channel, accountId, mediaUrls: payloadSummary.mediaUrls },
},
{
channelId: channel,
accountId: accountId ?? undefined,
},
);
if (sendingResult?.cancel) {
continue;
}
if (sendingResult?.content != null) {
effectivePayload = { ...payload, text: sendingResult.content };
payloadSummary.text = sendingResult.content;
}
} catch (err) {
// Don't block delivery on hook failure, but log it.
console.warn(`message_sending hook failed: ${String(err)}`);
}| if (hookRunner?.hasHooks("message_sent")) { | ||
| void hookRunner | ||
| .runMessageSent( | ||
| { | ||
| to, | ||
| content: payloadSummary.text, | ||
| success: true, | ||
| }, | ||
| { | ||
| channelId: channel, | ||
| accountId: accountId ?? undefined, | ||
| }, | ||
| ) | ||
| .catch(() => {}); | ||
| } |
There was a problem hiding this comment.
The .catch(() => {}) for the message_sent hook silently swallows errors. This makes debugging plugins difficult. Please add logging to report hook failures. This also applies to the message_sent hook in the error handling block below (lines 421-436).
if (hookRunner?.hasHooks("message_sent")) {
void hookRunner
.runMessageSent(
{
to,
content: payloadSummary.text,
success: true,
},
{
channelId: channel,
accountId: accountId ?? undefined,
},
)
.catch((err) => {
console.warn(`message_sent hook (on success) failed: ${String(err)}`);
});
}There was a problem hiding this comment.
Pull request overview
This pull request wires 9 previously unwired plugin hooks into the core codebase and fixes a ghost reminder bug in the heartbeat runner. The hooks enable plugins to intercept and observe key lifecycle events including session lifecycle, message delivery, gateway operations, tool execution, and compaction events.
Changes:
- Wires 9 plugin hooks (session_start, session_end, message_sending, message_sent, gateway_start, gateway_stop, before_compaction, after_compaction, after_tool_call) with corresponding test coverage
- Fixes ghost reminder bug (openclaw#13317) where heartbeat noise events triggered false cron reminder notifications
- Adds comprehensive test suites for all new hook integrations
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/plugins/wired-hooks-session.test.ts | Unit tests for session_start and session_end hook runner methods |
| src/plugins/wired-hooks-message.test.ts | Unit tests for message_sending and message_sent hook runner methods |
| src/plugins/wired-hooks-gateway.test.ts | Unit tests for gateway_start and gateway_stop hook runner methods |
| src/plugins/wired-hooks-compaction.test.ts | Integration tests for before_compaction and after_compaction hooks in lifecycle handlers |
| src/plugins/wired-hooks-after-tool-call.test.ts | Integration tests for after_tool_call hook in tool execution handlers |
| src/infra/outbound/deliver.ts | Wires message_sending (with content modification/cancellation) and message_sent hooks into message delivery flow |
| src/infra/heartbeat-runner.ts | Adds filtering logic to distinguish real cron events from heartbeat noise, fixing ghost reminder bug |
| src/infra/heartbeat-runner.ghost-reminder.test.ts | End-to-end tests verifying ghost reminder bug fix with various event scenarios |
| src/infra/heartbeat-runner.cron-system-event-filter.test.ts | Unit tests for cron event filtering helper function |
| src/gateway/server.impl.ts | Wires gateway_start and gateway_stop hooks into gateway server lifecycle |
| src/auto-reply/reply/session.ts | Wires session_start and session_end hooks into session initialization flow |
| src/agents/pi-embedded-subscribe.handlers.tools.ts | Wires after_tool_call hook with duration tracking via module-level Map |
| src/agents/pi-embedded-subscribe.handlers.lifecycle.ts | Wires before_compaction and after_compaction hooks into compaction lifecycle handlers |
| CHANGELOG.md | Adds changelog entries for bug fixes and features |
| function createMockRegistry( | ||
| hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>, | ||
| ): PluginRegistry { | ||
| return { | ||
| hooks: hooks as never[], | ||
| typedHooks: hooks.map((h) => ({ | ||
| pluginId: "test-plugin", | ||
| hookName: h.hookName, | ||
| handler: h.handler, | ||
| priority: 0, | ||
| source: "test", | ||
| })), | ||
| tools: [], | ||
| httpHandlers: [], | ||
| httpRoutes: [], | ||
| channelRegistrations: [], | ||
| gatewayHandlers: {}, | ||
| cliRegistrars: [], | ||
| services: [], | ||
| providers: [], | ||
| commands: [], | ||
| } as unknown as PluginRegistry; | ||
| } |
There was a problem hiding this comment.
The createMockRegistry helper function is duplicated across three test files (wired-hooks-session.test.ts, wired-hooks-message.test.ts, and wired-hooks-gateway.test.ts) with identical implementations. Consider extracting this to a shared test utility file (e.g., src/test-utils/plugin-test-helpers.ts) to reduce duplication and make future maintenance easier.
| @@ -67,6 +70,8 @@ Docs: https://docs.openclaw.ai | |||
| - Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26. | |||
| - Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002. | |||
| - Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi. | |||
| - Agents: keep followup-runner session `totalTokens` aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8. | |||
| - Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct. | |||
There was a problem hiding this comment.
Several CHANGELOG entries appear unrelated to the plugin hooks wiring described in the PR title:
- Line 44: Discord DM reactions (fix: process Discord DM reactions instead of silently dropping them openclaw/openclaw#10418)
- Line 47: Discord media-only messages (fix(discord): omit empty content field in media-only messages openclaw/openclaw#9507)
- Line 73: Agents followup-runner totalTokens (Bug: sessions.json totalTokens not updated after compaction openclaw/openclaw#14979)
- Line 74: Discord channel-edit archive/lock (fix(discord): Add thread archive/lock support to channel-edit action openclaw/openclaw#5542)
These entries don't correspond to any code changes in the diffs. If this PR is intended only for wiring plugin hooks and fixing the ghost reminder bug (openclaw#13317), these unrelated entries should be removed. If this is a combined PR, the title and description should reflect all changes included.
| void hookRunner | ||
| .runSessionEnd( | ||
| { | ||
| sessionId: previousSessionEntry.sessionId, |
There was a problem hiding this comment.
The session_end hook is called with messageCount hardcoded to 0 when replacing an existing session. This means plugins won't receive accurate information about how many messages were in the ended session. Consider loading the actual message count from the session's message array or session file before calling the hook, or document this limitation if the message count is intentionally unavailable at this point in the session lifecycle.
| sessionId: previousSessionEntry.sessionId, | |
| sessionId: previousSessionEntry.sessionId, | |
| // NOTE: The accurate message count for the ended session is not available | |
| // at this point in the lifecycle, so we intentionally report 0 here. | |
| // Plugins should treat this as "unknown" rather than a literal count. |
… @shtse8
Verified:
Summary by Sourcery
Wire previously unused plugin hooks into core message delivery, session lifecycle, tool execution, compaction, gateway startup/shutdown, and heartbeat cron handling, and add targeted tests to validate these integrations.
New Features:
Bug Fixes:
Enhancements:
Tests:
Chores: