Skip to content

fix(auto-reply): restore route replies for exec-event completions#70258

Merged
steipete merged 3 commits into
openclaw:mainfrom
wzfukui:fix/openclaw-exec-event-delivery-context
Apr 23, 2026
Merged

fix(auto-reply): restore route replies for exec-event completions#70258
steipete merged 3 commits into
openclaw:mainfrom
wzfukui:fix/openclaw-exec-event-delivery-context

Conversation

@wzfukui

@wzfukui wzfukui commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • restore route-reply delivery for async exec-event replies by falling back to persisted session delivery context
  • keep the existing direct routing behavior when OriginatingChannel and OriginatingTo are present on the live turn
  • add regression coverage for system-event replies that must return to the originating channel surface

Root cause

Async completion turns can lose direct channel metadata by the time the reply is generated. In that case the router currently skips channel route-reply delivery entirely, so channel plugins never receive the final completion message.

Testing

  • pnpm vitest run src/auto-reply/reply/dispatch-from-config.test.ts

@greptile-apps

greptile-apps Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes async exec-event completions silently dropping their channel route-replies by falling back to the persisted session delivery context (deliveryContext / lastChannel / lastTo / lastAccountId) when the live turn carries no OriginatingChannel or OriginatingTo. The isSystemEventTurn guard that gates the fallback is consistent with the same three-provider grouping (heartbeat, cron-event, exec-event) already used in session.ts.

Confidence Score: 5/5

Safe to merge; the only finding is a minor test assertion gap that doesn't affect production behaviour.

The implementation is correct and consistent with existing patterns. The null guard in sendPayloadAsync prevents unintended routing when no channel metadata is available at all. The single P2 finding (missing accountId assertion in the new test) is a test-completeness suggestion, not a production defect.

No files require special attention.

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: 466-472

Comment:
**Missing `accountId` assertion in new test**

The test sets up `deliveryContext.accountId: "acc-1"` and `AccountId: undefined` precisely to exercise the `routeAccountId` fallback — but the `routeReply` expectation never asserts `accountId`. The new `routeAccountId` variable is the other half of this fix (it's passed to four `routeReply` call-sites), so leaving it unverified means the test would still pass even if the account-ID propagation regressed.

```suggestion
    expect(mocks.routeReply).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "telegram",
        to: "telegram:999",
        accountId: "acc-1",
      }),
    );
```

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

Reviews (1): Last reviewed commit: "fix(auto-reply): route exec-event replie..." | Re-trigger Greptile

Comment on lines +466 to +472
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
expect(mocks.routeReply).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "telegram:999",
}),
);

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.

P2 Missing accountId assertion in new test

The test sets up deliveryContext.accountId: "acc-1" and AccountId: undefined precisely to exercise the routeAccountId fallback — but the routeReply expectation never asserts accountId. The new routeAccountId variable is the other half of this fix (it's passed to four routeReply call-sites), so leaving it unverified means the test would still pass even if the account-ID propagation regressed.

Suggested change
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
expect(mocks.routeReply).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "telegram:999",
}),
);
expect(mocks.routeReply).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "telegram:999",
accountId: "acc-1",
}),
);
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: 466-472

Comment:
**Missing `accountId` assertion in new test**

The test sets up `deliveryContext.accountId: "acc-1"` and `AccountId: undefined` precisely to exercise the `routeAccountId` fallback — but the `routeReply` expectation never asserts `accountId`. The new `routeAccountId` variable is the other half of this fix (it's passed to four `routeReply` call-sites), so leaving it unverified means the test would still pass even if the account-ID propagation regressed.

```suggestion
    expect(mocks.routeReply).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "telegram",
        to: "telegram:999",
        accountId: "acc-1",
      }),
    );
```

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

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

Copy link
Copy Markdown

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: 3f07711469

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

suppressDirectUserDelivery: suppressAcpChildUserDelivery,
isRoutableChannel: routeReplyRuntime.isRoutableChannel,
});
const originatingTo = ctx.OriginatingTo;
const originatingTo = ctx.OriginatingTo ?? fallbackOriginatingTo;
const routeAccountId = ctx.AccountId ?? fallbackOriginatingAccountId;

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 Use fallback account for final route-reply sends

For system-event turns, this change correctly derives routeAccountId from persisted delivery context, but the main final-reply path in sendFinalPayload still calls routeReply with ctx.AccountId instead of the fallback. When exec-event/cron-event/heartbeat completions lose live account metadata (the regression this commit targets), final replies can still route with an undefined or wrong account, which breaks multi-account channel delivery or applies incorrect per-account messaging config.

Useful? React with 👍 / 👎.

@wzfukui wzfukui force-pushed the fix/openclaw-exec-event-delivery-context branch from 3f07711 to 863429f Compare April 23, 2026 01:08
@wzfukui

wzfukui commented Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

Updated the PR branch against latest main, resolved the merge conflict, and added the requested routeReply accountId assertion for the persisted delivery-context fallback test. Local targeted test passed: pnpm test src/auto-reply/reply/dispatch-from-config.test.ts.

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

Copy link
Copy Markdown

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: 863429fd24

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +334 to +335
const effectiveOriginatingChannel = ctx.OriginatingChannel ?? fallbackOriginatingChannel;
const effectiveOriginatingTo = ctx.OriginatingTo ?? fallbackOriginatingTo;

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 Honor sendPolicy for fallback-routed system-event replies

When a heartbeat/cron-event/exec-event turn is missing live origin fields, this code now reconstructs effectiveOriginatingChannel/effectiveOriginatingTo from session state and routes outbound replies there, but sendPolicy is still resolved later from the raw context (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider). In that scenario, channel rules are evaluated against exec-event instead of the actual routed channel (for example, telegram), so channel-based deny rules can be bypassed and a reply is still delivered. Please feed the effective fallback route into policy resolution so suppression is evaluated against the true outbound destination.

Useful? React with 👍 / 👎.

@martingarramon

Copy link
Copy Markdown
Contributor

Static review. LGTM-shape on the fallback direction.

One design question + two gaps:

  1. Stale-route risk at channel level. dispatch-from-config.ts:290-293 carries an explicit warning: "Do not read thread ids from the normalised session store here: origin.threadId can be folded back into lastThreadId/deliveryContext during store normalisation and resurrect a stale route after thread delivery was intentionally cleared." The PR now reads deliveryContext.channel / .to / .accountId from that same store at channel granularity. Does the same resurrection risk apply? Failure mode: a user who moved a Slack session to Telegram mid-life gets exec-event replies fired back to Slack.
  2. accountId revalidation. routeAccountId replaces ctx.AccountId in both route runtime and direct-dispatcher paths. If sessionStoreEntry.entry?.lastAccountId is a revoked/rotated profile, exec-event replies go through an authprofile the user meant to drop. Re-validating against current profiles would close it.
  3. Test matrix. Happy path covered. Worth adding: (a) exec-event with no deliveryContext AND no lastChannel (should not fabricate a route), (b) persistedDeliveryContext.channel diverging from both Provider and Surface (does suppressAcpChildUserDelivery still short-circuit correctly?).

@steipete steipete force-pushed the fix/openclaw-exec-event-delivery-context branch from fd6c33c to e85f6b6 Compare April 23, 2026 19:24
@steipete steipete merged commit 2b45bc1 into openclaw:main Apr 23, 2026
67 checks passed
@steipete

Copy link
Copy Markdown
Contributor

Landed via rebase onto main.

  • Gate: pnpm test src/auto-reply/reply/dispatch-from-config.test.ts; pnpm check:changed
  • Source head before merge: e85f6b6
  • Land commit: 2b45bc1

Thanks @wzfukui!

Kaspre added a commit to Kaspre/openclaw that referenced this pull request Apr 26, 2026
…xt loss

Exec completion events enqueued under cron or channel-specific session
keys were invisible to the heartbeat runner, which always checked the
agent's main session key. This caused exec results to silently vanish
instead of being relayed back to the originating channel.

1. Propagate sessionKey through scopedHeartbeatWakeOptions in bash
   exec, ACP spawn, gateway/node exec, and CLI watchdog paths
2. Add resolveEventSessionKey to remap cron session keys to the
   originating agent's main session key for event enqueueing

Scope was previously broader. Dropped per maintainer review feedback
on openclaw#50818 (issuecomment-4322767117):

- The forceLastTargetWhenNone override for resolveHeartbeatDeliveryTarget,
  which routed exec completions to the session's last target even when
  heartbeat.target was explicitly "none". Current main and its regression
  tests intentionally keep target="none" internal-only, so this routing
  change is left for a separate focused PR if it is ever revisited.
- The openclaw#39978 hook-cancel handling has already shipped via upstream PR
  openclaw#70258, so it is no longer part of this PR's scope.

The PR now closes openclaw#52305 only.

Closes openclaw#52305. Related: openclaw#18237.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
Kaspre added a commit to Kaspre/openclaw that referenced this pull request May 8, 2026
…xt loss

Exec completion events enqueued under cron or channel-specific session
keys were invisible to the heartbeat runner, which always checked the
agent's main session key. This caused exec results to silently vanish
instead of being relayed back to the originating channel.

1. Propagate sessionKey through scopedHeartbeatWakeOptions in bash
   exec, ACP spawn, gateway/node exec, and CLI watchdog paths
2. Add resolveEventSessionKey to remap cron session keys to the
   originating agent's main session key for event enqueueing

Scope was previously broader. Dropped per maintainer review feedback
on openclaw#50818 (issuecomment-4322767117):

- The forceLastTargetWhenNone override for resolveHeartbeatDeliveryTarget,
  which routed exec completions to the session's last target even when
  heartbeat.target was explicitly "none". Current main and its regression
  tests intentionally keep target="none" internal-only, so this routing
  change is left for a separate focused PR if it is ever revisited.
- The openclaw#39978 hook-cancel handling has already shipped via upstream PR
  openclaw#70258, so it is no longer part of this PR's scope.

The PR now closes openclaw#52305 only.

Closes openclaw#52305. Related: openclaw#18237.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
Kaspre added a commit to Kaspre/openclaw that referenced this pull request May 9, 2026
…xt loss

Exec completion events enqueued under cron or channel-specific session
keys were invisible to the heartbeat runner, which always checked the
agent's main session key. This caused exec results to silently vanish
instead of being relayed back to the originating channel.

1. Propagate sessionKey through scopedHeartbeatWakeOptions in bash
   exec, ACP spawn, gateway/node exec, and CLI watchdog paths
2. Add resolveEventSessionKey to remap cron session keys to the
   originating agent's main session key for event enqueueing

Scope was previously broader. Dropped per maintainer review feedback
on openclaw#50818 (issuecomment-4322767117):

- The forceLastTargetWhenNone override for resolveHeartbeatDeliveryTarget,
  which routed exec completions to the session's last target even when
  heartbeat.target was explicitly "none". Current main and its regression
  tests intentionally keep target="none" internal-only, so this routing
  change is left for a separate focused PR if it is ever revisited.
- The openclaw#39978 hook-cancel handling has already shipped via upstream PR
  openclaw#70258, so it is no longer part of this PR's scope.

The PR now closes openclaw#52305 only.

Closes openclaw#52305. Related: openclaw#18237.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Kaspre added a commit to Kaspre/openclaw that referenced this pull request May 10, 2026
…xt loss

Exec completion events enqueued under cron or channel-specific session
keys were invisible to the heartbeat runner, which always checked the
agent's main session key. This caused exec results to silently vanish
instead of being relayed back to the originating channel.

1. Propagate sessionKey through scopedHeartbeatWakeOptions in bash
   exec, ACP spawn, gateway/node exec, and CLI watchdog paths
2. Add resolveEventSessionKey to remap cron session keys to the
   originating agent's main session key for event enqueueing

Scope was previously broader. Dropped per maintainer review feedback
on openclaw#50818 (issuecomment-4322767117):

- The forceLastTargetWhenNone override for resolveHeartbeatDeliveryTarget,
  which routed exec completions to the session's last target even when
  heartbeat.target was explicitly "none". Current main and its regression
  tests intentionally keep target="none" internal-only, so this routing
  change is left for a separate focused PR if it is ever revisited.
- The openclaw#39978 hook-cancel handling has already shipped via upstream PR
  openclaw#70258, so it is no longer part of this PR's scope.

The PR now closes openclaw#52305 only.

Closes openclaw#52305. Related: openclaw#18237.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
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.

3 participants