Skip to content

fix(msteams): preserve channel thread isolation during proactive fallback#59314

Open
tazmon95 wants to merge 2 commits intoopenclaw:mainfrom
tazmon95:fix/msteams-channel-thread-isolation
Open

fix(msteams): preserve channel thread isolation during proactive fallback#59314
tazmon95 wants to merge 2 commits intoopenclaw:mainfrom
tazmon95:fix/msteams-channel-thread-isolation

Conversation

@tazmon95
Copy link
Copy Markdown

@tazmon95 tazmon95 commented Apr 2, 2026

Summary

When a bot reply in a Teams channel thread falls back to proactive messaging (due to turn context revocation), the reply previously posted as a new top-level message instead of staying in the thread. This PR fixes that by carrying the thread root message ID through the proactive send path.

Problem

In Microsoft Teams channels, conversations happen in threads. When OpenClaw receives a message in a thread, it responds via TurnContext.sendActivity(). However, Teams revokes the TurnContext after ~15 seconds, forcing a fallback to continueConversation() (proactive messaging). The proactive path was not thread-aware — it always sent to the channel top-level, breaking thread isolation.

This caused:

  • Bot replies appearing as new top-level posts instead of thread replies
  • Thread context loss for ongoing conversations
  • Confusing UX in busy channels with multiple threads

Fix

4 files changed, 5 files touched (including tests):

  1. conversation-store.ts: Add threadRootMessageId to StoredConversationReference — stores the thread root ID extracted from the inbound activity's ;messageid=… suffix.

  2. message-handler.ts: Populate threadRootMessageId from the already-parsed conversationMessageId when upserting conversation references.

  3. messenger.ts:

    • sendProactively() now sets activityId to threadRootMessageId for channel conversations and appends ;messageid=THREAD_ROOT to conversation.id so continueConversation() routes to the correct thread.
    • DM behavior is unchanged (activityId remains undefined).
    • When replyStyle=thread but no TurnContext is available, fall through to sendProactively() instead of throwing.
  4. send.ts: sendTextWithMedia() (message tool proactive path) detects channel threads and uses replyStyle: "thread" with the ;messageid= suffix instead of "top-level".

Testing

  • 462/462 tests pass (pnpm test:extension msteams)
  • 4 new tests covering:
    • Channel thread proactive fallback sets correct activityId and conversation.id
    • DM proactive fallback does NOT set activityId (preserves existing behavior)
    • Multiple channel threads don't cross-contaminate via proactive fallback
    • replyStyle=thread with no context falls through to sendProactively instead of throwing
  • All pre-commit hooks pass (lint, typecheck, security policy)

Real-world validation

This fix has been running in production on a self-hosted OpenClaw instance (2026.3.31) with Microsoft Teams for ~8 hours with correct thread routing confirmed across multiple channel threads and DMs.

AI Disclosure

  • AI-assisted (Claude) — code generation and test authoring
  • Fully tested (462/462 pass)
  • Human understands and has validated the changes
  • Pre-commit hooks all pass

— Fitzy 🐾

@openclaw-barnacle openclaw-barnacle Bot added channel: msteams Channel integration: msteams size: M labels Apr 2, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 2, 2026

Greptile Summary

This PR fixes channel thread isolation during proactive fallback in MS Teams: it stores the thread root message ID on the conversation reference and uses it in sendProactively to set activityId and append ;messageid=THREAD_ROOT to conversation.id, so Bot Framework routes the message into the correct thread instead of the channel top-level. DM behaviour is unchanged.

Two minor P2 concerns: the conversation.id mutation added in send.ts is immediately stripped by normalizeConversationId inside buildConversationReference and is therefore a no-op (thread routing actually flows through threadRootMessageId); and the sendProactiveActivityRaw path used for SharePoint/OneDrive file sends, FileConsentCard, polls, and adaptive cards remains thread-unaware, so file attachments in channel threads still post top-level.

Confidence Score: 5/5

  • Safe to merge — the primary text-reply thread-routing fix is correct, DM behaviour is preserved, and all remaining findings are P2.
  • All three inline comments are P2 (style, redundant mutation, and incomplete coverage of file-send paths). The core logic — storing threadRootMessageId, setting activityId, and appending the ;messageid= suffix in sendProactively — is correct and well-tested by four new focused tests. No P0/P1 defects found.
  • extensions/msteams/src/send.ts — redundant conversation.id mutation and thread-unaware sendProactiveActivityRaw paths.

Comments Outside Diff (1)

  1. extensions/msteams/src/send.ts, line 393-411 (link)

    P2 File send paths bypass thread routing

    sendProactiveActivityRaw always sets activityId: undefined and does not consult threadRootMessageId, so the SharePoint file card path (line ~245), the OneDrive markdown link path (line ~285), and the FileConsentCard path (line ~166) still post to the channel top-level even when a threadRootMessageId is stored. Only the sendTextWithMedia path and base64 image path are thread-aware after this PR. If a channel-thread user sends a file or an agent responds with a file attachment, the reply appears as a new top-level post — the exact UX problem the PR aims to fix for text messages.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extensions/msteams/src/send.ts
    Line: 393-411
    
    Comment:
    **File send paths bypass thread routing**
    
    `sendProactiveActivityRaw` always sets `activityId: undefined` and does not consult `threadRootMessageId`, so the SharePoint file card path (line ~245), the OneDrive markdown link path (line ~285), and the `FileConsentCard` path (line ~166) still post to the channel top-level even when a `threadRootMessageId` is stored. Only the `sendTextWithMedia` path and base64 image path are thread-aware after this PR. If a channel-thread user sends a file or an agent responds with a file attachment, the reply appears as a new top-level post — the exact UX problem the PR aims to fix for text messages.
    
    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: extensions/msteams/src/send.ts
Line: 337-346

Comment:
**Redundant `conversation.id` mutation is a no-op**

The `;messageid=` suffix appended here is immediately stripped by `normalizeConversationId` (which splits on `;`) inside `buildConversationReference`, then re-appended by `sendProactively` from `threadRootMessageId`. The thread routing is driven entirely by `threadRootMessageId` on the ref — the `conversation.id` mutation in `send.ts` has no observable effect and could mislead future maintainers into thinking it's load-bearing.

Consider removing the `conversation` override here and passing `ref` directly, since `sendProactively` already handles appending `;messageid=THREAD_ROOT` when `threadRootMessageId` is set:

```suggestion
  const proactiveRef = ref;
```

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

---

This is a comment left during a code review.
Path: extensions/msteams/src/send.ts
Line: 393-411

Comment:
**File send paths bypass thread routing**

`sendProactiveActivityRaw` always sets `activityId: undefined` and does not consult `threadRootMessageId`, so the SharePoint file card path (line ~245), the OneDrive markdown link path (line ~285), and the `FileConsentCard` path (line ~166) still post to the channel top-level even when a `threadRootMessageId` is stored. Only the `sendTextWithMedia` path and base64 image path are thread-aware after this PR. If a channel-thread user sends a file or an agent responds with a file attachment, the reply appears as a new top-level post — the exact UX problem the PR aims to fix for text messages.

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

---

This is a comment left during a code review.
Path: extensions/msteams/src/messenger.ts
Line: 534-537

Comment:
**Misleading comment about `conversation.id`**

The comment "The conversationRef already has `;messageid=THREAD_ROOT` in `conversation.id`" is inaccurate: `buildConversationReference` normalises the ID by splitting on `;` before `sendProactively` ever sees it. The suffix that ultimately lands in the proactive reference is re-appended by `sendProactively` itself from `threadRootMessageId`. The comment should describe what `sendProactively` does, not what `send.ts` put into `conversation.id`.

```suggestion
      // No TurnContext available (proactive send path). Fall through to sendProactively,
      // which will set activityId and ;messageid= on the conversation reference when
      // threadRootMessageId is present, routing the reply into the correct thread.
      return await sendProactively(messages, 0);
```

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

Reviews (1): Last reviewed commit: "fix(msteams): preserve channel thread is..." | Re-trigger Greptile

Comment on lines +337 to +346
const proactiveRef =
isChannel && ref.threadRootMessageId
? {
...ref,
conversation: {
...ref.conversation!,
id: `${ref.conversation!.id};messageid=${ref.threadRootMessageId}`,
},
}
: ref;
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 Redundant conversation.id mutation is a no-op

The ;messageid= suffix appended here is immediately stripped by normalizeConversationId (which splits on ;) inside buildConversationReference, then re-appended by sendProactively from threadRootMessageId. The thread routing is driven entirely by threadRootMessageId on the ref — the conversation.id mutation in send.ts has no observable effect and could mislead future maintainers into thinking it's load-bearing.

Consider removing the conversation override here and passing ref directly, since sendProactively already handles appending ;messageid=THREAD_ROOT when threadRootMessageId is set:

Suggested change
const proactiveRef =
isChannel && ref.threadRootMessageId
? {
...ref,
conversation: {
...ref.conversation!,
id: `${ref.conversation!.id};messageid=${ref.threadRootMessageId}`,
},
}
: ref;
const proactiveRef = ref;
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/send.ts
Line: 337-346

Comment:
**Redundant `conversation.id` mutation is a no-op**

The `;messageid=` suffix appended here is immediately stripped by `normalizeConversationId` (which splits on `;`) inside `buildConversationReference`, then re-appended by `sendProactively` from `threadRootMessageId`. The thread routing is driven entirely by `threadRootMessageId` on the ref — the `conversation.id` mutation in `send.ts` has no observable effect and could mislead future maintainers into thinking it's load-bearing.

Consider removing the `conversation` override here and passing `ref` directly, since `sendProactively` already handles appending `;messageid=THREAD_ROOT` when `threadRootMessageId` is set:

```suggestion
  const proactiveRef = ref;
```

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

Comment on lines +534 to +537
// No TurnContext available (proactive send path). Go straight to sendProactively
// with preserveThread=true so the message lands in the thread.
// The conversationRef already has ;messageid=THREAD_ROOT in conversation.id.
return await sendProactively(messages, 0);
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 Misleading comment about conversation.id

The comment "The conversationRef already has ;messageid=THREAD_ROOT in conversation.id" is inaccurate: buildConversationReference normalises the ID by splitting on ; before sendProactively ever sees it. The suffix that ultimately lands in the proactive reference is re-appended by sendProactively itself from threadRootMessageId. The comment should describe what sendProactively does, not what send.ts put into conversation.id.

Suggested change
// No TurnContext available (proactive send path). Go straight to sendProactively
// with preserveThread=true so the message lands in the thread.
// The conversationRef already has ;messageid=THREAD_ROOT in conversation.id.
return await sendProactively(messages, 0);
// No TurnContext available (proactive send path). Fall through to sendProactively,
// which will set activityId and ;messageid= on the conversation reference when
// threadRootMessageId is present, routing the reply into the correct thread.
return await sendProactively(messages, 0);
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/messenger.ts
Line: 534-537

Comment:
**Misleading comment about `conversation.id`**

The comment "The conversationRef already has `;messageid=THREAD_ROOT` in `conversation.id`" is inaccurate: `buildConversationReference` normalises the ID by splitting on `;` before `sendProactively` ever sees it. The suffix that ultimately lands in the proactive reference is re-appended by `sendProactively` itself from `threadRootMessageId`. The comment should describe what `sendProactively` does, not what `send.ts` put into `conversation.id`.

```suggestion
      // No TurnContext available (proactive send path). Fall through to sendProactively,
      // which will set activityId and ;messageid= on the conversation reference when
      // threadRootMessageId is present, routing the reply into the correct thread.
      return await sendProactively(messages, 0);
```

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

@tazmon95
Copy link
Copy Markdown
Author

tazmon95 commented Apr 2, 2026

Addressed in 8b6d0e1 — sendProactiveActivityRaw now applies the same thread-aware logic: checks for channel conversationType + threadRootMessageId, sets activityId and appends ;messageid= to conversation.id. This covers the SharePoint file card, OneDrive markdown link, and FileConsentCard paths.

The editMessageMSTeams and deleteMessageMSTeams functions were intentionally left unchanged — they target an existing message by its specific activityId (edit/delete), not posting new content, so thread routing doesn't apply to them.

462/462 tests still pass.

— Fitzy 🐾

tazmon95 added 2 commits April 3, 2026 08:47
…back

When a bot reply falls back to proactive messaging in a Teams channel
thread (due to turn context revocation), the proactive reference now
carries the original thread root message ID.  This ensures the reply
lands in the correct thread instead of posting as a new top-level
message.

Changes:
- conversation-store.ts: add threadRootMessageId to StoredConversationReference
- message-handler.ts: populate threadRootMessageId from the inbound
  activity's ;messageid=… suffix (already parsed by
  extractMSTeamsConversationMessageId)
- messenger.ts: sendProactively() sets activityId to threadRootMessageId
  for channel conversations (DMs unchanged). When replyStyle=thread but
  no TurnContext is available, fall through to sendProactively instead of
  throwing.
- send.ts: sendTextWithMedia() detects channel threads and uses
  replyStyle=thread with the ;messageid= suffix instead of top-level.
- messenger.test.ts: four new tests covering channel thread isolation,
  DM behavior preservation, cross-thread contamination prevention, and
  proactive thread fallback without context.

AI-assisted: yes (Claude). Fully tested (pnpm test:extension msteams — 462/462 pass).
…le sends

Address review feedback: sendProactiveActivityRaw (used by SharePoint
file cards, OneDrive markdown links, and FileConsentCard paths) was
still using activityId: undefined, causing file responses in channel
threads to post top-level. Apply the same thread-aware logic so all
proactive send paths respect threadRootMessageId.

AI-assisted: yes (Claude). Fully tested (462/462 pass).
@tazmon95 tazmon95 force-pushed the fix/msteams-channel-thread-isolation branch from 8ad5af9 to e2f5744 Compare April 3, 2026 15:48
@tazmon95
Copy link
Copy Markdown
Author

tazmon95 commented Apr 3, 2026

Rebased onto current main to resolve merge conflicts. Key change: since #55198 (by @hyojin) landed with a similar fix for the sendProactively revoked-context path, the rebased version now combines both approaches — the explicit threadActivityId parameter (from #55198) is preferred when available, with threadRootMessageId from the stored conversation reference as a fallback. This covers the additional paths not addressed by #55198:

  • sendTextWithMedia (message tool proactive path)
  • sendProactiveActivityRaw (SharePoint file cards, OneDrive links)
  • No-context fallthrough (replyStyle=thread without TurnContext)
  • Persistent threadRootMessageId in conversation store

All original test cases preserved + adapted for the merged logic.

— Fitzy 🐾

@grafoteka
Copy link
Copy Markdown

Confirming this bug still reproduces on v2026.4.21 (installed on a production OpenClaw deployment with MS Teams).

Reproduction

Gateway log excerpt (timestamps sanitized)HH:MM:00 msteams: received message

HH:MM:00 msteams: dispatching to agent
HH:MM:02 active-memory: session=agent:main:msteams:channel:...@thread.tacv2:thread: start
HH:MM:08 active-memory: done status=empty elapsedMs=6012
HH:MM:25 msteams:send: sent proactive message ← posts to channel top-level
HH:MM:27 msteams: dispatch complete

Total turn duration: ~69 seconds. The session key shows the thread id was correctly tracked inbound — the thread context is lost only at the proactive send step, exactly as this PR diagnoses.

Impact

Users perceive replies as appearing out-of-order in unrelated threads, breaking the threaded conversation model. With active-memory enabled (which adds ~6s baseline on top of any tool-call time), most channel turns exceed the 15s window and hit this path.

Validation

The threadRootMessageId extraction from the inbound activity's ;messageid= suffix is the correct source. Fix approach looks sound.

Would be great to see this merged. Happy to help validate on our deployment if that helps land it.

Thanks @Fitzy for the work.

@Fitzy
Copy link
Copy Markdown

Fitzy commented Apr 22, 2026

@grafoteka - thanks for the tag, however I think on this occasion the wrong Fitzy handle has definitely been tagged 😁

@grafoteka
Copy link
Copy Markdown

My apologies @Fitzy — I tagged the wrong handle. The PR author is @tazmon95 (the "Fitzy 🐾" signature in the commits is the same person under their real GitHub handle). Sorry for the noise.

@tazmon95 — the previous message stands: production confirmation on v2026.4.21 holds, happy to help validate this PR on our deployment.

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented May 1, 2026

Codex review: needs changes before merge.

Summary
The PR stores a Teams channel thread root on conversation references and uses it to route proactive text, media/file, raw activity, and no-context thread sends back into the originating channel thread.

Reproducibility: yes. The discussion gives a production repro on v2026.4.21 with a Teams channel thread, a long tool-using turn, and proactive fallback posting top-level; code inspection also shows current send.ts proactive paths ignore the stored thread id.

Next step before merge
A narrow automated repair is viable because the bug and affected files are clear, the PR is useful but not mergeable as-is, and the fix can be confined to MS Teams proactive send routing plus tests and changelog.

Security
Cleared: The diff is limited to Teams conversation-reference routing and tests, with no dependency, workflow, permission, secret-handling, or external execution changes.

Review findings

  • [P2] Gate stored thread fallback to thread replies — extensions/msteams/src/messenger.ts:503-508
  • [P3] Add the required changelog entry — extensions/msteams/src/send.ts:345
Review details

Best possible solution:

Reuse current main's StoredConversationReference.threadId, add a narrow thread-aware proactive send path for MS Teams message-tool/raw sends, preserve configured top-level behavior, and add focused regression tests plus a changelog entry.

Do we have a high-confidence way to reproduce the issue?

Yes. The discussion gives a production repro on v2026.4.21 with a Teams channel thread, a long tool-using turn, and proactive fallback posting top-level; code inspection also shows current send.ts proactive paths ignore the stored thread id.

Is this the best way to solve the issue?

No as written. The intended routing fix is valid, but adding threadRootMessageId and using it for every channel proactive send is broader than the existing threadId contract and breaks documented top-level reply style.

Full review comments:

  • [P2] Gate stored thread fallback to thread replies — extensions/msteams/src/messenger.ts:503-508
    The new effectiveThreadId falls back to the stored thread root for every proactive send from a channel reference, even when the caller requested replyStyle: "top-level". That breaks the documented Threads-style channel mode and the current guard that top-level sends must not append ;messageid=...; only thread fallback/current-thread sends should opt into this routing.
    Confidence: 0.86
  • [P3] Add the required changelog entry — extensions/msteams/src/send.ts:345
    This is a user-facing MS Teams delivery fix, but the PR does not update CHANGELOG.md. Add a single-line entry under the active ### Fixes section before merge, crediting the human contributor where appropriate.
    Confidence: 0.82

Overall correctness: patch is incorrect
Overall confidence: 0.86

Acceptance criteria:

  • pnpm test extensions/msteams/src/messenger.test.ts extensions/msteams/src/send.test.ts
  • pnpm exec oxfmt --check --threads=1 extensions/msteams/src/messenger.ts extensions/msteams/src/send.ts extensions/msteams/src/messenger.test.ts extensions/msteams/src/send.test.ts CHANGELOG.md
  • pnpm check:changed

What I checked:

Likely related people:

  • hyojin: Authored merged PR fix(msteams): preserve channel reply threading in proactive fallback #55198, which added the current sendProactively channel-thread reconstruction path this PR extends. (role: introduced adjacent behavior; confidence: high; commits: 739ed1bf2944; files: extensions/msteams/src/messenger.ts, extensions/msteams/src/messenger.test.ts)
  • sudie-codes: Recent MS Teams work added the stored threadId routing model and related channel-thread targeting tests that are the safer base for this fix. (role: introduced current thread-id model; confidence: high; commits: 9edfefedf7bf, 38aa1edf76da; files: extensions/msteams/src/conversation-store.ts, extensions/msteams/src/monitor-handler/message-handler.ts, extensions/msteams/src/messenger.ts)
  • steipete: Local blame on this shallow checkout attributes the current MS Teams messaging snapshot to a recent maintainer commit, and prior review history points to adjacent revoked-context fallback hardening in the same files. (role: recent maintainer; confidence: medium; commits: 335f870cd294, 089a8785b955, 0b7f6fa9d004; files: extensions/msteams/src/messenger.ts, extensions/msteams/src/send.ts, extensions/msteams/src/monitor-handler/message-handler.ts)

Remaining risk / open question:

  • No live Teams send was performed during this read-only review, so the exact Bot Framework behavior for the repaired raw activity path still needs targeted validation or maintainer deployment confirmation.
  • This PR overlaps the broader open MS Teams thread-isolation PR fix(msteams): isolate thread sessions, outbound targeting, and attachment resolution #59294, so a repair should stay narrowly scoped to proactive send routing and avoid pulling in unrelated session and attachment changes.

Codex review notes: model gpt-5.5, reasoning high; reviewed against 473fc0aad84a.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: msteams Channel integration: msteams size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants