fix(msteams): emit message:sent hook on reply delivery#82354
Conversation
The msteams reply-dispatcher previously bypassed both the internal hook
bus and the plugin-SDK message_sent hook entirely on outbound delivery,
leaving downstream listeners (per-user memory loggers, audit substrates)
unable to observe agent replies. Telegram emits both correctly via
extensions/telegram/src/bot/delivery.replies.ts:emitTelegramMessageSentHooks;
msteams had no equivalent module.
This patch mirrors the telegram pattern:
NEW extensions/msteams/src/delivery-hooks.ts
- buildMSTeamsSentHookContext(): canonical context with channelId="msteams"
- emitInternalMessageSentHook(): fires triggerInternalHook("message","sent",...)
gated on sessionKeyForInternalHooks
- emitMessageSentHooks(): fans out to both plugin-SDK and internal buses
- emitMSTeamsMessageSentHooks(): public entry point that resolves the
global hook runner and computes enabled state
MODIFIED extensions/msteams/src/reply-dispatcher.ts
- Calls emitMSTeamsMessageSentHooks at the end of flushPendingMessages,
after the (possibly partial) delivery attempt completes
- `to` resolves to recipient AAD for personal DMs (matches telegram's
chatId semantics) and falls back to conversationId for group/channel
- Tracks failureCount + lastError across the batch + per-message retry
paths so the hook reports success/error consistently
- Concatenates rendered text content for the hook payload
Hook handlers can use the `isGroup` + `groupId` flags to disambiguate
personal DMs from group/channel surfaces.
Tests: deferred (existing reply-dispatcher.test.ts scaffolding already
mocks hookRunner; will add explicit assertions in a follow-up commit
before upstream PR submission).
Validated against a real Teams DM round-trip: prior log-outbound-dm hook
(events: ["message"]) captured message:received and message:preprocessed
but never message:sent for msteams. CloudWatch evidence in the EVA
project's session notes.
Adds three vitest cases to reply-dispatcher.test.ts:
1. Personal-DM success path → emitMSTeamsMessageSentHooks called once
with channelId-implicit context (provider hardcodes "msteams"),
to=user.aadObjectId, success=true, messageId set, isGroup=false,
error undefined.
2. Group chat success path → to=conversationId, isGroup=true,
groupId=conversationId.
3. Full failure path (initial batch throws + per-message retries all
throw) → success=false, error populated, messageId undefined.
The hook module is mocked via vi.mock("./delivery-hooks.js") so the
test asserts the dispatcher's call shape rather than the downstream
triggerInternalHook plumbing (covered by hook-runtime's own tests).
|
No dependency changes detected. Learn more about Socket for GitHub. 👍 No dependency changes detected in pull request |
|
Codex review: needs changes before merge. Reviewed June 30, 2026, 9:23 PM ET / July 1, 2026, 01:23 UTC. Summary PR surface: Source +170, Tests +170. Total +340 across 3 files. Reproducibility: yes. Source inspection on current main shows personal-DM streaming can suppress block delivery and then finalize successfully without running the PR's queued-send hook path. Review metrics: 1 noteworthy metric.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof path suggestion Risk before merge
Maintainer options:
Copy recommended automerge instructionNext step before merge
Security Review findings
Review detailsBest possible solution: Rebase and emit one canonical MS Teams sent-hook outcome from both queued block sends and successful native stream finalization, with focused tests for each path. Do we have a high-confidence way to reproduce the issue? Yes. Source inspection on current main shows personal-DM streaming can suppress block delivery and then finalize successfully without running the PR's queued-send hook path. Is this the best way to solve the issue? No. The helper shape is reasonable, but the call site is too narrow; Slack's equivalent finalizer emits after successful stream close to cover the streaming happy path. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against b3b51b0c9185. Label changesLabel justifications:
Evidence reviewedPR surface: Source +170, Tests +170. Total +340 across 3 files. View PR surface stats
Acceptance criteria:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
This comment was marked as spam.
This comment was marked as spam.
Addresses ClawSweeper review finding [P2] on PR openclaw#82354. Pass `params.sessionKeyForInternalHooks` as the `sessionKey` field of the canonical hook context built in `buildMSTeamsSentHookContext`, not just as the internal-hook routing key. Telegram's `buildTelegramSentHookContext` does not include sessionKey either — but the canonical context type accepts it, and the plugin-SDK consumers reading the canonical/plugin context object (via `toPluginMessageContext`) can use it to correlate the sent event with the originating agent session. Including it costs nothing.
5e31d7d to
419da85
Compare
|
Thanks @yfge and @clawsweeper — both findings addressed in the rebased branch:
Branch rebased to @clawsweeper re-review Re-review progress:
|
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
ClawSweeper PR egg 🥚 Incubating: this PR egg is tucked into the review nest. Hatch commandComment Hatchability rules:
What is this egg doing here?
|
Summary
Closes a parity gap with telegram: the msteams reply-dispatcher
previously bypassed both the internal hook bus AND the plugin-SDK
message_senthook entirely on outbound delivery. Downstream listeners(audit-loggers, per-user memory substrates) cannot observe agent replies
on msteams as a result.
Telegram emits both correctly via
extensions/telegram/src/bot/delivery.replies.ts'semitTelegramMessageSentHooks(mirror ofemitInternalMessageSentHookemitMessageSentHooks). msteams had no equivalent module — this PRadds it.
Files
NEW
extensions/msteams/src/delivery-hooks.ts— mirrors thetelegram pattern:
buildMSTeamsSentHookContext()withchannelId: "msteams"ANDsessionKeypopulated fromsessionKeyForInternalHookssoplugin-SDK consumers can correlate
emitInternalMessageSentHook()gated onsessionKeyForInternalHooksemitMessageSentHooks()fans out to both the plugin-SDKmessage_sentrunner and the internal busemitMSTeamsMessageSentHooks()is the public entry point thatresolves the global hook runner
MODIFIED
extensions/msteams/src/reply-dispatcher.ts—flushPendingMessagesnow callsemitMSTeamsMessageSentHooksafterthe delivery attempt completes. The
tofield resolves to therecipient AAD for personal DMs (matching telegram's
chatIdsemantics) and falls back to the conversation id for group/channel
surfaces. The failure-tracking state was refactored to span both the
batch and per-message retry paths so
success/error/messageIdare reported consistently.MODIFIED
extensions/msteams/src/reply-dispatcher.test.ts—three new test cases:
to/success/messageId/isGroup=falseisGroup=true+groupIdsuccess=false+errorpopulated +messageIdundefinedBehavior
Internal hook emission is conditional on
sessionKeyForInternalHooksbeing set (same gate telegram uses). Plugin-SDK
message_senthookemission is conditional on
hookRunner.hasHooks("message_sent"). Hookhandlers can use the
isGroup+groupIdflags to disambiguatepersonal DMs from group/channel surfaces.
Real behavior proof (redacted production logs)
Verified end-to-end against a downstream consumer in production (an
internal-hook handler subscribed to
["message:sent"]that POSTs toan audit endpoint).
Before this patch — same downstream handler registered for the
catch-all
["message"]namespace, real Teams DM round-trip:grep -rn "triggerInternal\|emitInternal" extensions/msteams/src/onthe pre-patch source returned zero results, confirming the gap is
structural.
After this patch — same handler with
["message:sent"]registration, real Teams DM round-trip on the patched gateway:
Observations:
message:sentfires exactly twice — once for the stream-finalizeemit (contentLen=686) and once for a follow-up
flushPendingMessagesemit (contentLen=576). Both report
success=truewith the recipientAAD as
to(matches telegram'schatIdsemantics for personal DMs).hook firing.
How this was discovered
Internal-hook handler registered for the
messagenamespace caughtmessage:receivedandmessage:preprocessedevents for msteams butnever
message:sent.grep -rn "triggerInternal\|emitInternal\|hookRunner\|message_sent" extensions/msteams/src/on the pre-patch source returned zero matches, confirming the gap is
architectural rather than configuration. The fix matches the telegram
pattern verbatim modulo the channel-specific recipient identifier
semantics.
Test plan
pnpm exec vitest run extensions/msteams/src/reply-dispatcher.test.tspassesreply-dispatcher.test.tscases