Skip to content

fix(msteams): preserve thread context in proactive fallback for channel conversations#56608

Closed
tazmon95 wants to merge 3 commits intoopenclaw:mainfrom
tazmon95:fix/msteams-thread-proactive-fallback
Closed

fix(msteams): preserve thread context in proactive fallback for channel conversations#56608
tazmon95 wants to merge 3 commits intoopenclaw:mainfrom
tazmon95:fix/msteams-thread-proactive-fallback

Conversation

@tazmon95
Copy link
Copy Markdown

Problem

When a bot reply in a Teams channel thread takes long enough for the Bot Framework turn context proxy to be revoked, the fallback path (sendProactively) unconditionally clears activityId from the conversation reference:

const proactiveRef = { ...baseRef, activityId: undefined };

For channel conversations, activityId is the thread root message ID. Clearing it causes the proactive fallback message to land as a new top-level channel post instead of staying in the original thread.

This is disruptive for users — replies to a channel thread silently appear at the channel level, breaking conversation context and burying thread history.

Fix

Preserve activityId when conversationType === 'channel', so proactive fallback messages correctly target the original thread. For DMs (personal) and group chats (groupChat), activityId continues to be cleared (no thread targeting needed).

const conversationType =
  params.conversationRef.conversation?.conversationType?.toLowerCase();
const proactiveRef: MSTeamsConversationReference = {
  ...baseRef,
  activityId: conversationType === 'channel' ? baseRef.activityId : undefined,
};

Tests

Added two new test cases to messenger.test.ts:

  • preserves activityId in proactive fallback for channel conversations
  • clears activityId in proactive fallback for personal conversations

All 414 existing msteams extension tests continue to pass.

Validation

  • pnpm build
  • pnpm check ✅ (formatting, linting, type checking, all boundary checks)
  • pnpm test:extension msteams ✅ (37 test files, 414 tests)

Context

Discovered while debugging a production OpenClaw deployment where channel thread replies consistently landed at the channel level during long-running agent responses. The withRevokedProxyFallback path in sendMSTeamsMessages correctly detects the revoked proxy and falls back to proactive messaging, but the thread context was being lost in the transition.

— Fitzy 🐾

@openclaw-barnacle openclaw-barnacle bot added channel: msteams Channel integration: msteams size: S labels Mar 28, 2026
Copy link
Copy Markdown

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

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: 9e4ff96aa7

ℹ️ 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".

const proactiveRef: MSTeamsConversationReference = {
...baseRef,
activityId: undefined,
activityId: conversationType === "channel" ? baseRef.activityId : undefined,
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 Restrict activityId preservation to fallback thread replies

sendProactively is used by both the revoked-thread fallback path and the normal replyStyle: "top-level" path, but this change now keeps activityId for all channel conversations. In channels configured for top-level posting, that activityId will thread the response under the triggering message instead of creating a new post, which regresses the documented top-level behavior. The preservation logic should be gated to the fallback-from-thread scenario (or replyStyle === "thread") rather than all channel sends.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 28, 2026

Greptile Summary

This PR fixes a real production bug where proactive fallback messages in Teams channel threads were landing as new top-level channel posts instead of staying in the original thread. The root cause was sendProactively unconditionally clearing activityId (the thread-root message ID for channels), and the fix correctly preserves it only when conversationType === "channel".

The logic change and the two new test cases are sound. One thing worth confirming: sendProactively is shared between the revoked-proxy fallback path and the replyStyle: "top-level" direct path (line 543). With this fix, a top-level send for a channel conversation would also preserve activityId, routing the message into the thread rather than creating a new top-level post. There is no test covering replyStyle: "top-level" + conversationType: "channel", so the intended behavior in that combination is not verified. If top-level sends are never used for channel conversations in practice this is a non-issue, but an explicit test would confirm the contract either way.

Confidence Score: 5/5

Safe to merge; the fix is correct for the stated use case and all existing tests pass.

The core change is minimal, well-targeted, and accompanied by two new test cases. The only remaining concern is a test gap for the top-level + channel combination, which is a P2 suggestion that does not block merge.

No files require special attention; the P2 note on messenger.ts is a suggestion to add one more test case.

Important Files Changed

Filename Overview
extensions/msteams/src/messenger.ts Core fix is correct — preserves activityId for channel conversations in the proactive fallback. One subtle concern: sendProactively is shared between the revoked-proxy fallback path and the top-level replyStyle path, so the fix also changes behavior for replyStyle:"top-level" + conversationType:"channel" (no existing test covers that combination).
extensions/msteams/src/messenger.test.ts Two well-structured new test cases covering channel (preserves activityId) and personal (clears activityId) fallback paths. baseRef definition correctly includes activityId: "activity123" so assertions are meaningful.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/msteams/src/messenger.ts
Line: 502-506

Comment:
**`sendProactively` used by both fallback AND `top-level` path**

`sendProactively` is called in two places: once as the revoked-proxy fallback (the case this PR targets), and once unconditionally at line 543 for the `top-level` replyStyle.

With this change, a call to `sendMSTeamsMessages` with `replyStyle: "top-level"` and `conversationType: "channel"` will now *preserve* `activityId` — routing the message into the thread rather than creating a new top-level post. This contradicts the intent of the `top-level` replyStyle.

The existing test for top-level sends (`"sends top-level messages via continueConversation and strips activityId"`) uses `baseRef` with no `conversationType`, so `conversationType` resolves to `undefined` and `activityId` is still cleared — the test doesn't catch this case.

If `replyStyle: "top-level"` is never paired with channel conversations in practice this has no impact, but it would be worth adding an explicit test case to lock in the expected behavior, e.g.:

```ts
it("clears activityId in top-level send for channel conversations", async () => {
  const seen: { reference?: unknown; texts: string[] } = { texts: [] };
  const adapter: MSTeamsAdapter = {
    continueConversation: async (_appId, reference, logic) => {
      seen.reference = reference;
      await logic({ sendActivity: createRecordedSendActivity(seen.texts), ... });
    },
    ...
  };

  await sendMSTeamsMessages({
    replyStyle: "top-level",
    adapter,
    appId: "app123",
    conversationRef: { ...baseRef, conversation: { ...baseRef.conversation, conversationType: "channel" } },
    messages: [{ text: "hello" }],
  });

  const ref = seen.reference as { activityId?: string };
  expect(ref.activityId).toBeUndefined(); // or .toBe("activity123") — confirm intent
});
```

Alternatively, if top-level sends for channels *should* preserve the thread `activityId`, document that explicitly.

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

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

Comment on lines +502 to 506
const conversationType = params.conversationRef.conversation?.conversationType?.toLowerCase();
const proactiveRef: MSTeamsConversationReference = {
...baseRef,
activityId: undefined,
activityId: conversationType === "channel" ? baseRef.activityId : undefined,
};
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 sendProactively used by both fallback AND top-level path

sendProactively is called in two places: once as the revoked-proxy fallback (the case this PR targets), and once unconditionally at line 543 for the top-level replyStyle.

With this change, a call to sendMSTeamsMessages with replyStyle: "top-level" and conversationType: "channel" will now preserve activityId — routing the message into the thread rather than creating a new top-level post. This contradicts the intent of the top-level replyStyle.

The existing test for top-level sends ("sends top-level messages via continueConversation and strips activityId") uses baseRef with no conversationType, so conversationType resolves to undefined and activityId is still cleared — the test doesn't catch this case.

If replyStyle: "top-level" is never paired with channel conversations in practice this has no impact, but it would be worth adding an explicit test case to lock in the expected behavior, e.g.:

it("clears activityId in top-level send for channel conversations", async () => {
  const seen: { reference?: unknown; texts: string[] } = { texts: [] };
  const adapter: MSTeamsAdapter = {
    continueConversation: async (_appId, reference, logic) => {
      seen.reference = reference;
      await logic({ sendActivity: createRecordedSendActivity(seen.texts), ... });
    },
    ...
  };

  await sendMSTeamsMessages({
    replyStyle: "top-level",
    adapter,
    appId: "app123",
    conversationRef: { ...baseRef, conversation: { ...baseRef.conversation, conversationType: "channel" } },
    messages: [{ text: "hello" }],
  });

  const ref = seen.reference as { activityId?: string };
  expect(ref.activityId).toBeUndefined(); // or .toBe("activity123") — confirm intent
});

Alternatively, if top-level sends for channels should preserve the thread activityId, document that explicitly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/messenger.ts
Line: 502-506

Comment:
**`sendProactively` used by both fallback AND `top-level` path**

`sendProactively` is called in two places: once as the revoked-proxy fallback (the case this PR targets), and once unconditionally at line 543 for the `top-level` replyStyle.

With this change, a call to `sendMSTeamsMessages` with `replyStyle: "top-level"` and `conversationType: "channel"` will now *preserve* `activityId` — routing the message into the thread rather than creating a new top-level post. This contradicts the intent of the `top-level` replyStyle.

The existing test for top-level sends (`"sends top-level messages via continueConversation and strips activityId"`) uses `baseRef` with no `conversationType`, so `conversationType` resolves to `undefined` and `activityId` is still cleared — the test doesn't catch this case.

If `replyStyle: "top-level"` is never paired with channel conversations in practice this has no impact, but it would be worth adding an explicit test case to lock in the expected behavior, e.g.:

```ts
it("clears activityId in top-level send for channel conversations", async () => {
  const seen: { reference?: unknown; texts: string[] } = { texts: [] };
  const adapter: MSTeamsAdapter = {
    continueConversation: async (_appId, reference, logic) => {
      seen.reference = reference;
      await logic({ sendActivity: createRecordedSendActivity(seen.texts), ... });
    },
    ...
  };

  await sendMSTeamsMessages({
    replyStyle: "top-level",
    adapter,
    appId: "app123",
    conversationRef: { ...baseRef, conversation: { ...baseRef.conversation, conversationType: "channel" } },
    messages: [{ text: "hello" }],
  });

  const ref = seen.reference as { activityId?: string };
  expect(ref.activityId).toBeUndefined(); // or .toBe("activity123") — confirm intent
});
```

Alternatively, if top-level sends for channels *should* preserve the thread `activityId`, document that explicitly.

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

…el conversations

When a Teams channel thread reply falls back to proactive messaging
(due to turn context proxy revocation on long-running responses),
the activityId was unconditionally cleared. For channel conversations,
activityId identifies the thread root message — clearing it causes the
reply to land as a new top-level channel post instead of staying in
the thread.

This change preserves activityId when the conversation type is
'channel', so proactive fallback messages correctly target the
original thread. For DMs and group chats (where thread targeting
is not applicable), activityId continues to be cleared.

Includes two new test cases:
- Verifies activityId is preserved for channel conversation fallback
- Verifies activityId is cleared for personal conversation fallback
@tazmon95 tazmon95 force-pushed the fix/msteams-thread-proactive-fallback branch from 9e4ff96 to a8bc1ca Compare March 28, 2026 20:54
@tazmon95
Copy link
Copy Markdown
Author

Good catch on the top-level + channel path — updated the fix to use a preserveThread parameter on sendProactively() so the thread context is only preserved when called from the revoked-proxy fallback (where the original intent was thread), not from the explicit top-level path.

Changes in the force-push:

  • sendProactively() now accepts { preserveThread?: boolean } (defaults to false)
  • Only the onRevoked fallback passes preserveThread: true
  • Added the suggested test: clears activityId in top-level send for channel conversations
  • 415 tests passing (37 files)

— Fitzy 🐾

@tazmon95
Copy link
Copy Markdown
Author

Hey @onutc — would appreciate your eyes on this when you get a chance. This fixes a production issue where channel thread replies fall back to top-level channel posts when the Bot Framework turn context proxy is revoked (long-running agent responses).

The fix is minimal (preserves activityId in sendProactively only when called from the revoked-proxy fallback for channel conversations), and we added 3 test cases covering the thread, personal, and top-level+channel paths.

The failing CI check (checks-windows-node-test-6) is a pre-existing Windows flake in test/test-env.test.ts — unrelated to our changes.

— Fitzy 🐾

@BradGroux
Copy link
Copy Markdown
Contributor

Closing as superseded by #55198 which addresses the same channel thread regression in the proactive fallback path. Both PRs depend on #27224 being re-landed first.

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: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants