fix(msteams): rebase TeamsSDK patterns to simplify Teams Integration#76262
fix(msteams): rebase TeamsSDK patterns to simplify Teams Integration#76262heyitsaamir wants to merge 34 commits intoopenclaw:mainfrom
Conversation
|
Codex review: needs real behavior proof before merge. Summary Reproducibility: unclear. The review failed before ClawSweeper could establish a reproduction path. Real behavior proof Next step before merge Review detailsBest possible solution: Retry the Codex review after fixing the execution failure. Do we have a high-confidence way to reproduce the issue? Unclear. The review failed before ClawSweeper could establish a reproduction path. Is this the best way to solve the issue? Unclear. Retry the review first so ClawSweeper can evaluate the actual issue and fix direction. What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 555cfed534ff. |
Reapply the msteams SDK migration (originally on feat/msteams-sdk-migration) on top of upstream/main, resolving conflicts with parallel msteams work that landed upstream during our session. What got applied vs decisions made: CLEANLY APPLIED (3-way patch): - monitor.ts, monitor-handler.ts, polls.ts, reply-stream-controller.ts/.test.ts, reply-dispatcher.ts, attachments/download.ts, monitor.lifecycle.test.ts, monitor-handler/message-handler.ts, monitor-handler.types.ts, etc. - streaming-message.ts + .test.ts deletions WHOLESALE TAKE FROM ORIGINAL BRANCH (partial 3-way left broken cross-refs): - sdk.ts, sdk.test.ts, messenger.ts, feedback-reflection.ts, send-context.ts, send.test.ts KEPT UPSTREAM (deferred for separate cleanup): - extensions/msteams/package.json (still has jsonwebtoken/jwks-rsa per Peter's b3bc60a incremental approach) - src/plugins/contracts/package-manifest.contract.test.ts (consistent with package.json) - pnpm-lock.yaml (avoids lockfile churn; pnpm install --frozen-lockfile clean) ADAPTED: - Dockerfile matrix-sdk-crypto check now wraps upstream's new retry-loop in the if-matrix-bundled gate KNOWN TEST FAILURES (need eyes — see PR comment): - attachments.test.ts: 1 fail (pre-existing — warn meta arg shape changed in our migration but test wasn't updated) - reply-dispatcher.test.ts: 6 fails (pre-existing — tests mock old TeamsHttpStream, not updated for our ctx.stream rewrite) - send.test.ts: 4 fails (NEW from merge — upstream's send.ts changed media loading; our mocks need updating or take upstream's send.test.ts wholesale) UPSTREAM COMMITS POTENTIALLY MISSED (in wholesale-take files): - 08c4af0 fix(msteams): accept conversation id allowlists - e1840b8 fix(msteams): bind global audience tokens to app id - Channels turn-kernel refactor (ffe67e9 / 1ead1b2 / 9a9cd0c) — may be partially preserved in cleanly-patched files Static checks pass: pnpm check:changed is green (typecheck, lint, contract tests, import cycles, etc.). Manual testing required before merge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- monitor.ts: adaptSdkContext now uses ctx.reply() for channel and groupChat conversations (so the SDK threads outbound activities to the inbound's replyToId/serviceUrl) and ctx.send() only for personal DMs (where reply()'s blockquote-prepend is ugly). - messenger.ts: sendProactively passes resolvedThreadId on the non-thread fallback path so channel @mentions that fall through outbound.ts -> send.ts still land in the original thread instead of top-level. Live-validated: channel @mention -> bot replies in thread, threaded reply -> bot replies in same thread, no top-level leakage.
- user-agent.ts: add buildOpenClawUserAgentFragment() that returns just 'OpenClaw/<version>'. The SDK's Client.clone merges this with its own 'teams.ts[apps]/<sdk-version>' identifier — passing the full buildUserAgent() here would double-print the SDK token. - sdk.ts: pass the fragment via AppOptions.client.headers['User-Agent'] so the Teams backend can identify OpenClaw traffic for usage telemetry. Final UA looks like 'OpenClaw/<openclaw-version> teams.ts[apps]/<sdk-version>'.
…stream The new SDK throws StreamCancelledError synchronously from stream.emit/update when the user pressed Stop in Teams: Teams replies 403 to the next chunk update, the SDK flips _canceled, and any subsequent emit() throws. The old custom TeamsHttpStream either swallowed cancel or didn't expose this exception type, so the migration inherited an SDK behavior the original code didn't have to handle. Symptom on 2026-05-05: pressing Stop during a streaming reply caused an unhandled promise rejection that crashed the Node 24 process. Docker restarted the gateway about two minutes after each Stop click. Two related bugs surfaced once the crash was caught: the would-be block fallback re-delivered the full text as a second message (duplicate after Stop), and the typing-keepalive kept pulsing in Teams for the rest of the agent run because nothing told it to stop. reply-stream-controller.ts: - Wrap stream.update / stream.emit / stream.close in try/catch that swallows StreamCancelledError (matched by .name to dodge tsgo's SDK re-export resolution quirk). Latch a wasCanceled flag so subsequent calls short-circuit even if stream.canceled is stale. - preparePayload() returns undefined when the stream was canceled — the streamed prefix is already visible to the user, so dropping the payload prevents a duplicate block message from overriding the cancel intent. reply-dispatcher.ts: - Typing-keepalive gate now also checks streamController.wasCanceled() so typing pulses stop firing once Stop is observed. Otherwise the bot keeps pulsing for the rest of the (uncancellable) agent run. reply-stream-controller.test.ts: - 6 new regression tests cover: cancel-during-emit (the crash scenario), cancel-during-update, cancel-during-finalize, non-cancel error propagation, post-cancel inactivity, and dropped-payload-on-cancel. Live-validated: long streaming reply + Stop mid-stream -> stream freezes, no duplicate message, no zombie typing, container stays healthy.
Teams puts inline DM images and clipboard-pasted images on *.asm.skype.com URLs (e.g. us-api.asm.skype.com/v1/objects/<id>/views/imgo). The download path in attachments/download.ts already does a plain GET first and falls back to a Bearer-token retry on 401/403 — but the retry was gated on the URL being in DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST. asm.skype.com hosts were in DEFAULT_MEDIA_HOST_ALLOWLIST (download permitted) but not in the auth-host list, so a 401 plain-GET response skipped the retry and surfaced as a missing image to the agent. Add asm.skype.com and ams.skype.com to the auth allowlist so openclaw attempts the Bearer-token retry consistently, matching how it treats the other CDN/Bot-Framework hosts already in the list. Note: this does not unblock all clipboard-pasted DM images — for at least some tenants asm.skype.com rejects the Bot Framework token (returns 401 even with auth). Routing those URLs through <serviceUrl>/v3/attachments/... the way openclaw#62219 already handles HTML-wrapped attachments is a separate follow-up. The +button 'Upload from this device' path works today because Teams generates an attachment with an HTML wrapper that triggers the existing BF v3 attachments fallback in monitor-handler/inbound-media.ts.
…efault
The plugin defaults webhook.port to 3978 (the Bot Framework standard used in
Microsoft samples) and listens on whatever the operator sets there. The
docker-compose.yml port mapping was exposing ${OPENCLAW_MSTEAMS_PORT:-3000}:3000
which only works for operators who explicitly set webhook.port to 3000.
Default-config users would have the plugin listening on 3978 inside the
container while compose forwarded 3000, causing connection refused.
Realign to ${OPENCLAW_MSTEAMS_PORT:-3978}:3978 so a default-config docker
compose up Just Works with Teams. Operators wanting a custom port override
both webhook.port in openclaw.json and OPENCLAW_MSTEAMS_PORT env var.
Three follow-ups after rebasing the SDK migration onto current main: - reply-dispatcher.ts: rename createChannelReplyPipeline to its post-rebase identifier createChannelMessageReplyPipeline (the plugin-sdk barrel renamed it during the 1454-commit rebase window). - reply-dispatcher.ts: tighten the typing-keepalive onStartError signature to (err: unknown) to satisfy upstream's stricter type checks. - messenger.ts: drop the unconditional thread suffix on the bottom proactive fallback. The previous behavior threaded all top-level proactive sends when the stored ref had a threadId, which contradicts replyStyle='top-level' semantics (and breaks the new upstream test). Threading on the proactive path is preserved where it matters — the onRevoked branch within replyStyle==='thread' still passes resolvedThreadId, which is the original openclaw#55198 fix path. - attachments.test.ts: update the warn-call assertion to match the migration's inline message format (host=... error=...) — the structured meta object was being dropped by the logger formatter pre-migration.
8e2009e to
e38f323
Compare
While the SDK migration was open, upstream landed preview/progress/draft
streaming features built on the OLD custom TeamsHttpStream class (which the
migration deletes). This commit ports the user-visible parts of those
features onto the new ctx.stream substrate so the migration doesn't lose
ground:
- pickInformativeStatusText: reads custom labels from
msteams.streaming.progressDraft config via resolveChannelProgressDraftLabel.
Falls back to the plugin-sdk default rotation. Pre-rebase used a hardcoded
4-string array.
- streamMode resolution: "partial" (default, per-token streaming),
"progress" (no tokens; preview card carries informative label that updates
as tools run), or "block" (no native streaming). Mode is read from
cfg.channels.msteams.streaming.preview.
- progress-draft gate: createChannelProgressDraftGate gates informative
updates so the rotating label only starts firing once meaningful work has
begun (avoids flicker before the first tool call).
- noteProgressWork() / pushProgressLine(): public methods on the controller
for callers (typing keepalive ticks, tool-event callbacks) to signal work.
pushProgressLine appends tool names as bullets above the rotating label
when streaming.previewToolProgress is enabled. Wiring these into actual
tool events is a separate follow-up.
- preparePayload progress-mode path: when stream is active but no tokens
streamed (progress mode) and a final text payload arrives, emit the text
into the stream so the preview card transitions in place to the final
reply on close().
reply-dispatcher: pass log + msteamsConfig + a stable progressSeed
(${accountId}:${conversation.id}) to createTeamsReplyStreamController so the
informative-label rotation is consistent across reconnects.
What's NOT ported and why:
- Live-edit-via-replaceInformativeWithFinal: the SDK's HttpStream natively
accumulates emitted text + entities + channelData and flushes ONE final
activity at close() using the same activity id as the preview. So the
separate "replace informative with final" call from upstream is
unnecessary — we get live-finalization for free via the SDK's design.
- pushProgressLine triggers from tool events: needs reply-pipeline-side
callbacks the new SDK migration didn't surface yet. Follow-up.
Tests: existing 22 reply-stream-controller tests still pass (the new
behaviors are additive).
…test debt
Two follow-ups from yesterday's stopping point:
1. Wire pipeline events into the stream controller's progress-draft surface.
reply-dispatcher's replyOptions now exposes onReasoningStream, onToolStart,
onItemEvent, onPlanUpdate, onApprovalEvent, onCommandOutput callbacks that
format each event via the channel-streaming helpers and route through
streamController.pushProgressLine(). Mirrors the discord adapter's wiring.
Also:
- resolveChannelStreamingPreviewToolProgress + ...SuppressDefaultTool... so
the dispatcher exposes suppressDefaultToolProgressMessages on its
replyOptions when progress mode is on.
- Switch disableBlockStreaming resolution to the channel-streaming helpers
(resolveChannelPreviewStreamMode + resolveChannelStreamingBlockEnabled)
so streaming.mode='block' and streaming.block.enabled=true are honored
alongside the legacy blockStreaming boolean.
2. Fix the test debt that the rebase exposed:
- reply-dispatcher.test.ts: drop the streamInstances + TeamsHttpStream
mock pattern (file deleted by migration); replace with a streamMock
provided via context.stream that mirrors the SDK's IStreamer shape
(update/emit/close/canceled). Update assertions on sendInformativeUpdate
-> stream.update, stream.update -> stream.emit. Drop the
resumes-typing-between-segments test (no equivalent in the new
ctx.stream model — the SDK's HttpStream doesn't have a 'between
segments' notion; close ends the stream).
- send.test.ts: fix two stale mock targets — loadOutboundMediaFromUrl
comes from openclaw/plugin-sdk/outbound-media (not /msteams), and
resolveMarkdownTableMode comes from openclaw/plugin-sdk/markdown-table-runtime
(not /config-runtime). The previous mock paths were no-ops post-migration.
All 854 msteams tests now pass (was 17 failing in 4 files yesterday).
…d sends
Two narrow regressions exposed by the @microsoft/teams.apps migration:
- The SDK's HttpStream.emit appends each chunk to its internal buffer
(`this.text += activity.text`), but the channel reply pipeline emits
cumulative text on each chunk. Forwarding cumulative text into an
appending sink produced "chunk1 + chunk1chunk2 + chunk1chunk2chunk3..."
duplication for streamed (DM) replies. Track the emitted prefix length
in the stream controller and only forward the new tail.
- Replace the manual `${convId};messageid=${msgId}` URL construction in
the proactive thread fallback with `app.reply()`, which builds the
threaded conversation id via the SDK's own toThreadedConversationId
helper. Mechanically equivalent today; removes coupling to Teams' URL
format and tracks any future SDK changes.
Also adds the `reply` method to the structural MSTeamsApp type so the
refactor typechecks without casts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2.0.10 adds support for the AAD v1 token issuer that the Bot Framework JWT validator needs. The minor version bump pulls teams.cards / common / graph along to 2.0.10 too. Add `@microsoft/teams.*` to `minimumReleaseAgeExclude` in pnpm-workspace.yaml because 2.0.10 was published <48h ago and the default `minimumReleaseAge: 2880` (~2 days) would otherwise reject it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ation-v2 # Conflicts: # extensions/msteams/src/send-context.ts # extensions/msteams/src/send.ts
These hosts were added in dfc169d for inline DM image auth-retry, but the commit's own footnote acknowledges it doesn't actually unblock clipboard-pasted images (asm.skype.com rejects Bot Framework tokens in at least some tenants). The change is unrelated to the SDK migration and the user-visible bug it claimed to fix isn't fixed; lifting it out keeps this PR focused on the migration. Will land as a separate PR if the auth-allowlist consistency improvement is wanted on its own. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…amid
The monitor's SDK bootstrap had an awkward chain:
httpServerAdapter: new (
(await import("@microsoft/teams.apps")) as unknown as {
ExpressAdapter: new (app: unknown) => unknown;
}
).ExpressAdapter(expressApp) as never,
Three casts (`unknown`, structural shape literal, `never`) were a
defensive workaround from when the SDK's hashed d.ts files tripped up
tsgo. With the SDK's exports now resolving cleanly, the same import can
be done with full types.
- Extend the lazy `loadSdkModules()` cache to include `ExpressAdapter`
alongside `App` so the dynamic import is shared.
- Add `createMSTeamsExpressAdapter(serverOrApp)` helper in `sdk.ts` that
encapsulates the lazy import and returns a properly-typed adapter
instance.
- Replace `httpServerAdapter`'s structural shape on `CreateMSTeamsAppOptions`
with the SDK's own `IHttpServerAdapter` interface (re-exported from
`@microsoft/teams.apps`).
The call site in `monitor.ts` becomes a single typed call with no `any`,
no `unknown`, no `as never`. The lazy-load behavior is preserved: nothing
imports `@microsoft/teams.apps` at module load time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CI's check-prod-types failed because the previous commit's typed helper
used `typeof import("@microsoft/teams.apps").ExpressAdapter`, which
tsc/tsgo's NodeNext resolution can't follow through the SDK's chained
`export *` barrel:
@microsoft/teams.apps/dist/index.d.ts:
export * from "./http"; // folder with index.d.ts
export * from "./app"; // single .d.ts file
The folder re-export drops `ExpressAdapter` and `IHttpServerAdapter` from
the namespace shape under `tsconfig.extensions.json` (passes under the
per-extension `tsconfig.json` because of inherited `paths`). Same root
cause as why we already model `MSTeamsApp` structurally (line 47 comment).
Switch the ExpressAdapter side to the same structural-shape pattern:
- Define `MSTeamsHttpServerAdapter` and `MSTeamsExpressAdapterCtor` locally.
- Cast `m.ExpressAdapter` once inside `loadSdkModules` (the runtime export
is fine; only the type surface is hidden).
- `httpServerAdapter` on `CreateMSTeamsAppOptions` and the return type of
`createMSTeamsExpressAdapter` use the local structural type.
Net result: the call site in `monitor.ts` stays the cast-free single line
the previous commit landed; the one remaining cast is confined to the
SDK-loading helper with an explanatory comment.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The SDK migration removed all `import "jsonwebtoken"` / `import "jwks-rsa"` from source code (the SDK does JWT validation internally now), but the package.json entries and the matching `package-manifest.contract.test.ts` expectation were left orphaned. Drop both: - `extensions/msteams/package.json`: remove `jsonwebtoken` (^9), `jwks-rsa` (^4) from `dependencies` and `@types/jsonwebtoken` from `devDependencies`. - `src/plugins/contracts/package-manifest.contract.test.ts`: remove the two entries from msteams's `pluginLocalRuntimeDeps` expectation. - `monitor.lifecycle.test.ts`: extend the `./sdk.js` mock with the `createMSTeamsExpressAdapter` export added in the typed-helper cleanup, so the lifecycle suite still mounts after the deps drop. Lockfile regenerates accordingly. All msteams tests (865) pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CI's deadcode:dependencies (knip) flagged @microsoft/teams.api as
unused in extensions/msteams. The plugin source uses structural type
aliases (MSTeamsActivityParams, MSTeamsActivityLike, etc.) to dodge
tsgo resolution bugs with teams.api's hashed d.ts files, so it never
imports teams.api directly. The package is brought in transitively
via @microsoft/teams.apps; the only other reference is
probe.test.ts's vi.mock("@microsoft/teams.api"), which works on the
import-path string and doesn't require a direct dep declaration.
Lockfile regenerates accordingly. tsgo:extensions, knip, and all
865 msteams tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds extensions/msteams/src/auth-coverage.test.ts driving ServiceTokenValidator and createEntraTokenValidator directly with jose-minted RS256 tokens against an in-memory JWKS (via JwksClient.prototype patch). Locks in the three contract cases @BradGroux flagged on openclaw#76262: aud=<bot app id> accepted, aud=api.botframework.com rejected even when appid/azp match, and v1/v2 issuers accepted for allowed tenant (disallowed tenant rejected). Drops a stale ambient module declaration in src/types/microsoft-teams-sdk.d.ts that was shadowing the SDK's real jwt-validator types with a long-renamed createServiceTokenValidator surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…roken invokeResponse send Brad openclaw#2 / codex openclaw#4 on PR openclaw#76262 — `ctx.sendActivity({ type: "invokeResponse", ... })` no longer reaches Teams as an HTTP InvokeResponse on the new SDK; it becomes an outbound Bot Framework activity instead. Move file-consent accept/decline to typed `app.on("file.consent.accept|decline", ...)` handlers. The SDK's typed-route layer wraps a void return into `{ status: 200 }` (`app.process.js:130`), so the manual ack disappears. While in here, type `MSTeamsApp.on` properly. Borrowing the SDK's `App.on` directly fails because that function carries a `this: App<TPlugin>` constraint our structural alias can't satisfy, so we model an equivalent generic over `IRoutes` with route-specific overloads (`card.action`, `file.consent.*`, `activity`). The overloads work around a tsgo bug — the `@microsoft/teams.api` `Activity` discriminated union collapses to `any`, turning `ActivityRoutes` into a `[string]: RouteHandler<X, void>` index signature that swallows every typed `Out` not already void-compatible (card.action returns `AdaptiveCardActionResponse`; the others happen to include `void`). Real tsc resolves cleanly. Linked upstream: microsoft/typescript-go#1057. Other cleanups: - Cast-free call sites for `adaptSdkContext` (now returns `MSTeamsTurnContext` instead of `unknown`). - card.action error responses include `innerHttpError` per the SDK's `HttpError` shape requirement. - Activity catch-all also skips `fileConsent/invoke` now that it's typed-routed (parallel to the existing `adaptiveCard/action` skip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oken invokeResponse send Brad openclaw#2 / codex openclaw#4 on PR openclaw#76262, SSO half. Continue the typed-route migration: `signin/tokenExchange` and `signin/verifyState` now register via `app.on("signin.token-exchange" | "signin.verify-state", ...)`. Per the SDK's router, registering a user route with the same name as a system route removes the system default — so the SDK's built-in handlers (which would call `api.users.token.exchange` themselves and emit a `signin` event nobody currently subscribes to) are silenced, and only ours runs. The SDK wraps a void return into the HTTP 200 InvokeResponse, so the legacy `ctx.sendActivity({ type: "invokeResponse", ... })` ack — broken on the new SDK because it becomes an outbound BF activity instead of the HTTP response — is gone. The handler body is extracted from the activity-catch-all dispatch in `monitor-handler.ts` to a new `signin-invoke.ts`, parallel to `file-consent-invoke.ts`. `isSigninInvokeAuthorized` is now exported from `monitor-handler.ts` so the new handler can reuse it. The activity catch-all skips the SSO invoke names alongside the existing skips for `adaptiveCard/action` and `fileConsent/invoke`. `MSTeamsAppOn` overloads now cover the two SSO routes with their typed ctx (`ISignInTokenExchangeInvokeActivity` / `ISignInVerifyStateInvokeActivity`). Tests in `monitor-handler.sso.test.ts` were rewritten to call the extracted handler directly — the `registered.run(ctx)` shape no longer covers SSO, and the `expect(ctx.sendActivity).toHaveBeenCalledWith({ type: "invokeResponse" })` assertions were dropped to match the new contract (the SDK ack happens via the typed-route return value). Note on overlap with openclaw#77784 (Stefan Stüben, Microsoft): that PR is doing a much bigger SSO rework (sign-in card / sign-in-link / six-digit-code fallbacks plus a `ctx.auth` plumbed to plugin tools). This change is the small migration-correctness fix and is structured so openclaw#77784's SSO body changes drop into the typed-route registrations cleanly on rebase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pp.on
Last invoke off the activity catch-all dispatch. `message/submitAction`
(thumbs up/down on AI-generated messages) now registers via
`app.on("message.submit", ...)`. Same shape as file-consent and SSO:
handler body extracted to a new `feedback-invoke.ts`, the SDK wraps a
void return into the HTTP 200 InvokeResponse, the broken
`ctx.sendActivity({ type: "invokeResponse", ... })` line is gone, and
the activity catch-all skips this invoke name alongside the others.
`isFeedbackInvokeAuthorized` is exported from `monitor-handler.ts` so
`feedback-invoke.ts` can reuse it. Tests in
`monitor-handler.feedback-authz.test.ts` were rewritten to call the
extracted handler directly — the old `handler.run(ctx)` shape no longer
intercepts feedback, and `originalRun` was removed because the typed
route is the dispatch point now.
`MSTeamsAppOn` overload added with the typed
`IMessageSubmitActionInvokeActivity` ctx, slotted between the SSO
overloads and the `activity` catch-all so `activity` stays last.
This leaves only `message`, `conversationUpdate`, and `messageReaction`
flowing through `app.on("activity", ...)` → `handler.run`. Promoting
those is the path to deleting the catch-all entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ils mid-flight codex openclaw#5 / Galin F5 on PR openclaw#76262. `reply-stream-controller.ts` previously re-threw any non-cancel error from `stream.emit` during partial streaming and from `stream.emit`/`stream.close` during finalize. Combined with `preparePayload` suppressing block delivery once `tokensEmitted` was true, that meant a network blip or API error mid-stream produced a truncated reply with no recovery — the user saw the prefix that made it through and nothing else. Add a `streamFailed` latch parallel to `canceledLocally` / `tokensEmitted`: - `onPartialReply`: catch non-cancel errors, set `streamFailed = true`, log a warn, don't propagate (the pipeline must keep running so `preparePayload` can decide). - `preparePayload`: when `tokensEmitted && streamFailed`, fall through to block delivery instead of suppressing. The user may see a duplicate (streamed prefix + full block reply); intentional — matches the pre-migration `TeamsHttpStream.hasContent` recovery and is better than truncated-only. - `finalize`: same latch + warn on non-cancel close failure, swallow rather than throw. The streamed content already reached the user; the closing activity (AI-Generated marker, feedback channelData) is the only loss, not worth blowing up the dispatcher. - `isStreamActive` returns false once the stream has failed. New tests cover crash-mid-stream after tokens were emitted (assert block delivery payload is returned), happy-path no-duplicate behavior (assert `preparePayload` still suppresses when nothing failed), and finalize close-failure (assert no throw). The pre-existing "re-throws non-cancel" test was inverted to assert non-throwing latch behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Type-only `import("@microsoft/teams.api/dist/...").TypeName` references
in `sdk.ts` (added when typed `MSTeamsApp.on` overloads were introduced)
are picked up by the `extension-runtime-dependencies` contract test as
genuine runtime imports. Declaring `@microsoft/teams.api` as a direct
dep makes the contract pass; the package was already coming in
transitively via `@microsoft/teams.apps`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @BradGroux and @galiniliev for the careful review. I worked through the open items and did another live Teams pass against the branch. Status
SSO correctionLive testing showed replacing the SDK's built-in The branch now leaves SDK sign-in routes in control, passes Live proof: with Docker Compose + real Teams app + devtunnel, a temporary local InvokeResponse handling
Live validationConfirmed live with Docker Compose + real Teams app + devtunnel:
The earlier JWT hypothesis was not the final root cause. The first SSO failure was Bot Framework returning Targeted local checks after cleanup: pnpm exec oxfmt --check --threads=1 extensions/msteams/src/monitor.ts extensions/msteams/src/sdk.ts extensions/msteams/src/signin-invoke.ts extensions/msteams/src/monitor.lifecycle.test.ts
pnpm test extensions/msteams/src/monitor.lifecycle.test.ts extensions/msteams/src/monitor-handler.sso.test.ts
pnpm tsgo:extensionsAll passed. Push-back on per-conversation
|
…ation-v2 # Conflicts: # extensions/msteams/package.json # extensions/msteams/src/send.test.ts # extensions/oc-path/src/oc-path/emit.ts # extensions/oc-path/src/oc-path/index.ts # extensions/oc-path/src/oc-path/jsonl/resolve.ts # extensions/oc-path/src/oc-path/tests/scenarios/perf-determinism.test.ts # extensions/oc-path/src/oc-path/tests/scenarios/roundtrip-property.test.ts # src/commands/path.ts # src/index.ts # src/oc-path/ast.ts # src/oc-path/dispatch.ts # src/oc-path/edit.ts # src/oc-path/find.ts # src/oc-path/jsonc/edit.ts # src/oc-path/jsonc/emit.ts # src/oc-path/jsonc/parse.ts # src/oc-path/jsonc/resolve.ts # src/oc-path/jsonl/edit.ts # src/oc-path/jsonl/emit.ts # src/oc-path/oc-path.ts # src/oc-path/parse.ts # src/oc-path/resolve.ts # src/oc-path/sentinel.ts # src/oc-path/slug.ts # src/oc-path/tests/edit.test.ts # src/oc-path/tests/emit.test.ts # src/oc-path/tests/fixtures/real/TOOLS.md # src/oc-path/tests/jsonc/edit.test.ts # src/oc-path/tests/jsonc/parse.test.ts # src/oc-path/tests/jsonc/resolve.test.ts # src/oc-path/tests/jsonl/edit.test.ts # src/oc-path/tests/jsonl/emit.test.ts # src/oc-path/tests/jsonl/parse.test.ts # src/oc-path/tests/oc-path.test.ts # src/oc-path/tests/resolve.test.ts # src/oc-path/tests/scenarios/append-multi-agent.test.ts # src/oc-path/tests/scenarios/byte-fidelity.test.ts # src/oc-path/tests/scenarios/code-blocks.test.ts # src/oc-path/tests/scenarios/edit-emit-roundtrip.test.ts # src/oc-path/tests/scenarios/h2-block-split.test.ts # src/oc-path/tests/scenarios/items.test.ts # src/oc-path/tests/scenarios/jsonl-byte-fidelity.test.ts # src/oc-path/tests/scenarios/oc-path-parse-edges.test.ts # src/oc-path/tests/scenarios/tables.test.ts # src/oc-path/tests/slug.test.ts # src/oc-path/tests/yaml/yaml-kind.test.ts # src/oc-path/universal.ts # src/oc-path/yaml/ast.ts # src/oc-path/yaml/edit.ts # src/oc-path/yaml/emit.ts # src/oc-path/yaml/parse.ts # src/oc-path/yaml/resolve.ts
…ation-v2 # Conflicts: # extensions/msteams/src/reply-stream-controller.test.ts
e109b56 to
20fa7f9
Compare
|
Thanks for the follow-up and the fixes. I re-reviewed The earlier auth and invoke blockers look addressed. The added coverage now locks inbound Bot Framework audience handling to the bot app id, covers the v1 and v2 Entra issuer paths, and I agree we should not restore acceptance of inbound I still have two pre-merge recommendations:
|
…gration-v2 # Conflicts: # extensions/msteams/src/reply-stream-controller.test.ts
|
@BradGroux Addressed! |
Hey folks! 👋 Quick context before you dive in: I'm one of the maintainers of
@microsoft/teams.ts— the TypeScript SDK this PR migrates onto. Happy to answer questions about the SDK side and help reconcile any incompatibilities.The Microsoft Bot Framework SDK is deprecated and its patterns predate Teams as a first-class platform. The "boilerplate tax" of staying on it shows up clearly in this diff — most of the ~1300 deleted lines are things like a custom JWT validator, a no-op HTTP adapter, hand-rolled streaming-card protocol with
streaminfoentities, manual REST calls to the Bot Connector for sends/edits/deletes, and a custom turn-context shim. The new@microsoft/teams.appsSDK absorbs all of that into typed, supported primitives (ExpressAdapter,tokenManager,ctx.stream,app.api.conversations.activities(), typed invoke routes forcard.action/file.consent.*, and SDK-owned sign-in routes). That's why the diff is net negative even though it adds substantive new functionality (proper streaming with feedback loops, Action.Execute card handling, fixed edit/delete plumbing).Summary
Migrate the msteams plugin from the legacy Bot Framework SDK to
@microsoft/teams.apps2.0.x.The migration replaces ~580 lines of custom plumbing (JWT validator, HTTP adapter shim, manual REST calls, token casting, custom streaming protocol) with built-in SDK primitives. Migration scope (
extensions/msteams/+ lockfile/workspace metadata): +1518 / -3608 lines, net -2090. Dropsjsonwebtoken+jwks-rsafromextensions/msteams/package.jsonalong with their transitives (the SDK does JWT validation internally now).Why
The old Bot Framework SDK forced us to:
tokenManagerresults throughunknownbecause internals weren't typedserviceUrl/v3/conversations/...for sends, edits, deletes@microsoft/teams.apps2.0.x ships all of this. Migrating reduces surface area we own, removes deps with their own audit footprint, and gets us on the Microsoft-supported codepath for future protocol changes.Bugs fixed (not refactor-only)
This PR finishes the SDK migration started in earlier commits. It is not a refactor-only PR — it fixes concrete user-visible bugs introduced when the SDK swap left adapter mismatches behind:
ctx.reply()runs typing activities throughTypingActivity.from()which strips our customstreaminfoentities. OursendActivitymapping was routed throughreply(), so streaming chunks went out without correlated stream IDs and Teams never collapsed the streaming card to the final message. Fixed by routingsendActivity → ctx.send()and replacing the entire custom streaming pipeline with the SDK's built-inctx.stream.feedbackLoopEnabledchannelData on each chunk; the SDK'sHttpStream.close()only carries entities/channelData accumulated from emittedMessageActivitys. Fixed by emitting an empty finalMessageActivitywithaddAiGenerated()+addFeedback()channelData beforeclose().Action.Submit+msteams.type: "messageBack"— a legacy Bot Framework pattern. With the new SDK, button clicks never reached the bot at all (zero log entries). Fixed by migrating the poll card toAction.Execute(Universal Action Model) and registeringapp.on('card.action', ...)that returns a real typedInvokeResponse. Without the typed response, Teams shows "Unable to reach app" after the 5s invoke timeout.ctx.updateActivity/ctx.deleteActivitydon't exist on the new SDK context — only on the old Bot FrameworkTurnContext. Calls were silently failing. Fixed by wiring them throughapp.api.conversations.activities(id).update/delete()inadaptSdkContext.ctx.sendActivity({ type: "invokeResponse" })quietly POSTed garbage. The new SDK has no special-case forinvokeResponseactivities; our adapter routed them toctx.send()which POSTed them to Bot Framework as regular outbound activities. Bot Framework rejected them silently. File-consent, card, and feedback invokes now use SDK route/return semantics; SSO intentionally stays on the SDK's built-in sign-in routes so Teams gets the right 200/412 fallback behavior.adapterfield that no longer existed. The original migration commit renamedMSTeamsMessageHandlerDeps.adapter→appbut three test fixtures (monitor-handler.adaptive-card.test.ts,monitor-handler/message-handler.test-support.ts,monitor-handler/reaction-handler.test.ts) and one test (monitor.lifecycle.test.ts) didn't get updated. Caught by the changed-gate now.package-manifest.contract.test.tsstill expectedjsonwebtokenandjwks-rsain msteams's plugin-local deps — both were removed in the original migration commit (the SDK does JWT validation internally now).appIddestructures in five places insend.ts, an unusedMSTEAMS_WEBHOOK_MAX_BODY_BYTESconst + import inmonitor.ts, an orphanedrequireConversationIdtest helper, andString(token)triggeringno-base-to-stringinsdk.ts. All caught bypnpm check:changed.Items 1–5 are user-visible Teams bugs caused by the SDK shape mismatch; 6–8 are gate-blocking debt left in the prior commit.
Follow-up fixes
These six fixes landed on top during a regression-test pass. Each is its own commit so they can be reviewed independently.
Thread routing for channel and group-chat replies. (regression fix)
adaptSdkContextnow usesctx.reply()for channels/groupChats (so the SDK threads outbound activities to the inbound'sreplyToId/serviceUrl) andctx.send()only for personal DMs (wherereply()'s blockquote-prepend is ugly). Companion fix inmessenger.ts:sendProactivelypassesresolvedThreadIdon the non-thread fallback so channel @mentions that route throughoutbound.ts → send.tsstill land in the original thread. Live-validated: thread reply → bot reply in same thread, no top-level leak.OpenClaw User-Agent on outbound SDK calls. (observability) New
buildOpenClawUserAgentFragment()returns justOpenClaw/<version>. The SDK'sClient.clonemerges that with its ownteams.ts[apps]/<sdk-version>identifier, so the final UA looks likeOpenClaw/<openclaw-version> teams.ts[apps]/<sdk-version>. Lets the Teams backend identify OpenClaw traffic for usage telemetry.StreamCancelledErrorwhen user presses Stop mid-stream. (regression fix) Real, gateway-crashing regression found during the regression-test pass. SDK throwsStreamCancelledErrorsynchronously fromstream.emit/updatewhen Teams replies 403 to the next chunk update (Stop button or 2-min timeout). Old customTeamsHttpStreameither swallowed cancel or didn't expose this exception type, so the migration inherited an SDK behavior the original code didn't have to handle. Result: Node 24 unhandled rejection → process crash → Docker restart, ~2 minutes after each Stop click. Fixed with try/catch around allstream.emit/update/closecalls +wasCanceledlatch that drops the would-be block fallback (so no duplicate message lands) and suppresses typing-keepalive (so no zombie typing pulses for the rest of the agent run). 6 new regression tests cover the crash and dedupe scenarios.SDK streaming delta tracking +
app.replyfor proactive thread sends. (one regression fix and one refactor, in the same commit)HttpStream.emitappends each chunk to its internal buffer (this.text += activity.text), but the channel reply pipeline emits cumulative text on each chunk. Forwarding cumulative into an appending sink produced "chunk1 + chunk1chunk2 + chunk1chunk2chunk3..." duplication for streamed (DM) replies. Track the emitted prefix length in the stream controller and only forward the new tail.app.reply()inmessenger.ts:sendProactively(refactor, not a regression fix — mechanically equivalent to the manual URL build it replaces) — replaced the manual${convId};messageid=${msgId}URL construction withapp.reply(), which builds the threaded conversation id via the SDK's owntoThreadedConversationIdhelper. Removes coupling to Teams' URL format and tracks any future SDK changes; the threaded-proactive-reply behavior was already correct via follow-up fix: add @lid format support and allowFrom wildcard handling #1'sresolvedThreadIdplumbing. Adds thereplymethod to the structuralMSTeamsApptype so the refactor typechecks without casts.Bump
@microsoft/teams.apiandteams.appsto 2.0.10. (dependency bump that resolves a Codex review item) 2.0.10 includes the AAD v1 token issuer support from microsoft/teams.ts#556 — the SDK now accepts the v1sts.windows.net/{tenantId}/issuer alongside the existing v2 issuer by default. This obviates the JWT validator issuer concern from the Codex review (item 1 below). Also adds@microsoft/teams.*topnpm-workspace.yaml'sminimumReleaseAgeExcludebecause 2.0.10 was published <48h ago and the defaultminimumReleaseAge: 2880(~2 days) would otherwise reject it.SSO sign-in invokes now use the SDK defaults, with OpenClaw token persistence. (regression fix found during live SSO testing) The first SSO pass registered explicit
app.on("signin.token-exchange")/app.on("signin.verify-state")handlers. That replaced the SDK's built-in system routes, which was wrong: when Bot Framework returnedConsent Required, our handler effectively turned the failed silent exchange into a successful HTTP 200, preventing Teams from continuing the consent fallback correctly. The fix leaves the SDK's built-in sign-in routes in control so Teams gets the SDK's expected 200/412 InvokeResponse semantics, passeschannels.msteams.sso.connectionNameinto the SDK as the default OAuth connection, and persists successful delegated tokens from the SDKsigninevent into OpenClaw's msteams SSO token store. Live-validated against a real Teams app through devtunnel:msteams sso token persistedand a Graph delegated token was written for connectiongraph.What changed
Auth + bootstrap
ExpressAdapterfrom@microsoft/teams.appshandles route registration and JWT validation internally — replaces our custom multi-issuer validatorapp.tokenManager.getBotToken()/.getGraphToken()(public API) replaces our manual token acquisition +unknowncasts. A smalltokenToString()helper callsIToken.toString()explicitly to keep the lint cleanAuthorizationheader before the SDK does JSON body parsing)SSO
signin.token-exchange/signin.verify-statesystem routes stay in control. OpenClaw intentionally does not replace them with user routes because the SDK defaults preserve Teams' required sign-in InvokeResponse semantics, including412when silent token exchange needs interactive consent fallback.channels.msteams.sso.connectionNameis passed into the SDK app as the default OAuth connection name, so the SDK verify-state handler uses the same connection OpenClaw is configured for.signinevent and persists successful delegated tokens into the msteams SSO token store. This gives us token persistence without reimplementing the sign-in invoke protocol.Outbound messaging
app.send(conversationId, activity)replacesadapter.continueConversation()for proactive sendsapp.reply(conversationId, messageId, activity)for proactive sends that need to land in an existing channel thread (uses the SDK's owntoThreadedConversationIdhelper, no manual;messageid=URL building)app.api.conversations.activities(id).update/delete()replaces manual REST calls for edit/deleteInbound dispatch + context adapter (the bridge)
The new SDK delivers an
IActivityContext(withsend/replymethods) but our handler chain expected the oldMSTeamsTurnContextinterface (withsendActivity/sendActivities/updateActivity/deleteActivity).adaptSdkContextinmonitor.tsbridges them:sendActivity→ctx.reply()for channel/groupChat (threaded),ctx.send()for personal DMs (no blockquote)updateActivity→app.api.conversations.activities(id).update(activityId, activity)deleteActivity→app.api.conversations.activities(id).delete(activityId)stream→ passthrough ofctx.streamStreaming via
ctx.streamDeleted
streaming-message.ts(~300 lines of custom streaminfo entity management). The SDK'sHttpStream(exposed viactx.stream) handles the full Teams streaming protocol —update(text)for informative status,emit(chunk)for streaming chunks,close()for the final message.createTeamsReplyStreamControlleris now a small bridge that wires openclaw's reply-pipeline callbacks toctx.stream. Two protocol mismatches surfaced and got fixed inline:HttpStream.emit(activity)appends each chunk to its internal buffer (this.text += activity.text). The controller tracks the emitted prefix length and forwards only the new tail (follow-up commit 5).emit/update/closeplus awasCanceledlatch (follow-up commit 3).Before close, the controller emits a final empty
MessageActivitycarrying theAIGeneratedContententity andfeedbackLoopEnabled: truechannelData — the SDK'sHttpStreamaccumulates that into the closing activity, so streamed messages still get the AI-generated label and thumbs up/down.Adaptive card actions
The poll card was using
Action.Submit+msteams.type: "messageBack"(legacy Bot Framework pattern). With the new SDK, vote clicks weren't reaching the bot at all. Migrated toAction.Execute(Universal Action Model) which sends anadaptiveCard/actioninvoke.Registered
app.on('card.action', ...)that returns a realInvokeResponse. Poll vote detection is inlined in the handler — extractspollIdandchoicesfrom the newvalue.action.datapayload shape (vs the oldvalue.openclawPollId), records the vote viapollStore, and returns{ statusCode: 200, type: 'application/vnd.microsoft.activity.message', value: 'Vote recorded.' }. Without a typedInvokeResponse, Teams shows "Unable to reach app" after the 5s invoke timeout.Removed (dead/replaced)
streaming-message.tsand its test — replaced byctx.streamMSTEAMS_WEBHOOK_MAX_BODY_BYTESconst + import — unusedrequireConversationIdtest helper inmessenger.test.ts— orphanedappIddestructures insend.ts— leftover from the earlier auth pass-through refactorjsonwebtoken,jwks-rsa,@types/jsonwebtoken, and@microsoft/teams.apifromextensions/msteams/package.json— the migration removed all source-code imports (SDK does JWT validation internally;teams.apiis brought in transitively viateams.appsand the plugin uses structural type aliases to dodge tsgo resolution issues withteams.api's hashed d.ts files), and this PR also drops the orphaned package.json entries and the matchingpackage-manifest.contract.test.tsexpectationPolish
attachments/download.ts: improved"attachment download failed"warn (host + error inline in the message text — the structured meta object was being dropped by the logger formatter)adapter→apponMSTeamsMessageHandlerDeps(was renamed in the original migration commit but several test fixtures didn't get updated, causing pre-existing tsgo failures that the changed-gate now catches)jsonwebtoken/jwks-rsafrom msteams expected plugin-local deps (the new SDK does JWT validation internally — those packages are gone)Validated end-to-end
Tested live in a Teams app installation behind a devtunnel, with a deliberate regression-pass against the closed-issue history of the msteams plugin.
ctx.streamAction.Execute/ UAM)message/submitAction)+button→ BF v3 attachments path, #62219)app.api.conversations.activities().update/delete)app.replypath)graph)Side-by-side parity check. The migration build was validated against a parallel
main-branch bot (OpenMAINClaw, separate Azure App ID + tunnel + config) running on the same host. Channel @-mention with default config produced identical behavior on both bots (message_tool_onlyis the documented default for group/channel chats — the agent must call themessage(action=send)tool to reply, or operators setmessages.groupChat.visibleReplies: "automatic"to opt in to auto-replies). After flipping both configs toautomatic, channel @-mentions, threaded replies, proactive sends, edits, and adaptive cards all produced equivalent output on the migration build vsmain. No regression introduced by the SDK swap.pnpm check:changedis green: typecheck, lint, contract tests, and all msteams unit tests pass (865 tests). Two pre-existing failures on the branch (parallels-smoke-model.test.ts,tui.test.ts) are unrelated to msteams.Not tested / still out of scope:
Codex review findings
@clawsweeper's review surfaced four items I worked through as part of the regression-test pass.sts.windows.net): addressed upstream by microsoft/teams.ts#556 which accepts the v1sts.windows.net/{tenantId}/issuer alongside the existing v2 issuer by default. Picked up in this PR via the@microsoft/teams.api/teams.apps2.0.10 bump (commite9e9034f8b). No openclaw-side change needed.aud=https://api.botframework.com): intentionally not restoring this. The Entra access-token spec requiresaudto be the app's clientId; tokens withaud=https://api.botframework.comare spec-violating, and the SDK's deliberate scope-narrowing in Docs/hetzner guide #556 (commitda3c94c: "drop audience changes from auth PR") confirms that's the correct stance. The pre-rebase custom validator that accepted the global audience was a compatibility shim for legacy/canary tokens, not a property of well-formed Entra tokens. We won't carry it forward.app.send(conversationId, activity)does strip the conversation-specifictenantId/aadObjectId/serviceUrlfrom the ref, but manually validated live in this tenant by issuingopenclaw message send --channel msteams --target <id>to all three conversation types: DM (returned message id 1778029455243), groupChat (1778029476443), channel (1778029496328). All three landed in Teams with no 403 — Bot Framework accepts the call with the bot's tenant-scoped credentials and resolves routing from the conversation ID alone. Pre-rebaseadapter.continueConversation(ref, ...)had functionally identical behavior at this layer (modulo the explicit ref it carried). The original [Bug] Microsoft Teams: HTTP 403 on proactive messages in v2026.3.31 + RSC consent blocks bot installation in team #58774 report may have been v2026.3.31-specific or tenant-specific; we'll re-open this if any user reports a 403 in the wild post-migration./api/messagesroute registration — fixed with a compatibility forwarder. The SDK registers the configuredwebhook.path; when that path is not/api/messages, OpenClaw also accepts legacy/api/messagesPOSTs and forwards them to the configured path with a one-time deprecation warning. This keeps existing Azure Bot registrations working through the transition while still nudging operators to update the endpoint.docker-compose.ymlnow exposes${OPENCLAW_MSTEAMS_PORT:-3978}:3978to match the plugin's defaultwebhook.port(3978, also the Bot Framework default used in Microsoft samples). Operators wanting a custom port override bothwebhook.portin config andOPENCLAW_MSTEAMS_PORTenv var.Known followups (intentionally out of scope, separate PRs)
Productize SSO usage beyond token persistence. The SDK sign-in routes are now the active path: OpenClaw leaves
signin.token-exchange/signin.verify-stateto the SDK and persists successful tokens from the SDKsigninevent. Live testing confirmed a Graph delegated token is written for the configuredgraphconnection. Follow-up work is to expose a normal user-facing sign-in entrypoint and wire the persisted token into tool/runtime auth surfaces. The old explicitsignin-invoke.tshelper and lower-levelsso.tsexchange helpers can likely be simplified or removed once the dedicated SSO/tooling path lands.Replace catch-all activity dispatch with per-type SDK routes: today we register
app.on("activity", ...)as a catch-all that pattern-matchesactivity.type/activity.nameinternally via abuildActivityHandlershim mimicking the old Bot FrameworkActivityHandler.run()shape. The new SDK has typed routes for every activity type/invoke we care about —app.on("message"),app.on("conversationUpdate"),app.on("messageReaction"),app.on("file.consent.accept"|"decline"),app.on("message.submit.feedback"), etc. This is what slack does (extensions/slack/src/monitor/events/*.tsregisters ~10 specific Bolt handlers, no catch-all). Splitting dropsbuildActivityHandler+ theMSTeamsActivityHandlerinterface + theif (ctx.activity?.type === "invoke" && ctx.activity?.name === "...")chain in the wrapper, gives each handler a typed context, and lets the SDK auto-ack invoke responses for paths that don't need a typed response. Keep SDK sign-in routes as SDK-owned unless/until we have a concrete reason to replace their 200/412 fallback behavior.Clipboard-paste DM image handling — Teams puts inline DM images directly on
*.asm.skype.comURLs without an HTML<attachment>wrapper, so they don't trigger the existing BF-v3-attachments fallback. The +button "Upload from this device" path works because Teams generates the HTML wrapper. Fixing clipboard-paste means extracting the object ID from the asm.skype.com URL and rewriting to<serviceUrl>/v3/attachments/<id>/views/imgo.Re-implement upstream's preview / progress-draft / live-finalization features on top of
ctx.stream. While this PR was open, upstream landed #77674 / #78081 and several other commits that extended the OLDTeamsHttpStreamclass with: preview-mode streaming, progress-draft labels (e.g. "Looking up the schema..." that updates as tools run), and live-finalization that edits the preview-stream activity in place when the final reply lands. This PR deletesTeamsHttpStreamin favor of the SDK'sctx.stream, so those features need to be re-built on the new substrate. Scope is moderate (~200-300 lines): track preview-stream-id from the SDK'sHttpStream, route progress-draft text throughctx.stream.update(...)instead of a customsendInformativeUpdate, and useapp.api.conversations.activities(id).update(activityId, ...)for the in-place final edit. The plugin-sdk helpers (createLiveMessageState,defineFinalizableLivePreviewAdapter,createChannelProgressDraftGate, etc.) already work shape-agnostically — they just need a differenteditFinaladapter that uses the SDK API instead of the customTeamsHttpStreammethods. Out of scope for this PR to keep the migration scope contained.Rebase / merge notes
This branch was force-rebased onto
upstream/mainon 2026-05-06 (1454 upstream commits since the original migration commit). The rebase took ours wholesale for the 6 conflicting files in commit8e90628cb6:monitor.ts,reply-dispatcher.ts,reply-stream-controller.ts,reply-stream-controller.test.ts,sdk.ts,sdk.test.ts(took migration's version)streaming-message.tsand its test (deleted by the migration; upstream had modified them)Follow-up commit
e38f323173 fix(msteams): post-rebase reconciliation with mainreconciled the rest:createChannelReplyPipeline→createChannelMessageReplyPipeline(renamed in plugin-sdk during the rebase window)onStartErrortyping for upstream's stricter type checksreplyStyle: 'top-level'should not thread; the original fix(msteams): preserve channel reply threading in proactive fallback #55198 fix is preserved on thereplyStyle === 'thread'onRevokedbranch)attachments.test.tswarn assertion to match the migration's inline message formatOn 2026-05-07 the branch was merged with
upstream/mainagain (1820 commits since the prior rebase point). Two conflicts resolved in commit10629d08df:extensions/msteams/src/send.tsandsend-context.ts: kept the SDK'sappover main's legacyadapter, adopted main's newreplyStylepropagation throughMSTeamsProactiveContext(so CLI proactive sends to channel threads pick the policy-resolved replyStyle and route throughmessenger.ts:sendProactivelywith the thread root id, leveraging follow-up commit 4'sapp.replypath).Same merge commit also rewrote two tests in
extensions/msteams/src/messenger.test.ts(not conflict-marked but materially changed): the tests referenced the deletedMSTeamsAdapter/noopUpdateActivity/noopDeleteActivityand were ported to the migration'sMSTeamsAppmock pattern.All msteams unit tests (865) pass after the merge.
Real behavior proof
Behavior or issue addressed: SDK-migration end-to-end behavior on the live Teams runtime — DM streaming (with the cumulative-vs-delta tracking fix from follow-up #4), DM streaming with tool-progress informational updates above the streaming card, channel proactive + threaded-reply messaging (follow-up #1), and SSO token persistence via the SDK
signinevent. Together these exercisectx.stream,app.on("card.action"),ctx.replyfor channels, the proactiveapp.send/app.replypaths, and the SDK default sign-in invoke routes.Real environment tested: Microsoft Teams app installed in an M365 tenant via devtunnel, against a docker-deployed openclaw gateway running this branch. A separate
main-build control bot was installed in the same tenant for side-by-side parity comparison; behaviors below were observed equivalent on both bots aftermessages.groupChat.visibleReplies: "automatic"was set, so no migration-only regression.Exact steps or command run after this patch:
openclaw message send --channel msteams --target <channelId> --message …, then reply to that message in a thread inside Teams./test-signinhook that calledctx.signin({ connectionName: "graph" }), complete consent, and verify token persistence.Evidence after fix:
Basic streaming in DMs:
streaming.and.feedback.mp4
Streaming with tool-call informational updates:
streaming.tool.progress.mp4
Messaging in channels (proactive top-level + threaded reply):
Channel.threads.mp4
SSO flow working:
Observed result after fix:
app.reply()proactive thread path verified mechanically equivalent to the previous manual;messageid=URL build.msteams sso token persisted, and a Graph delegated token was written to~/.openclaw/msteams-sso-tokens.jsonfor connectiongraph./test-signinhook and JWT diagnostic logging used for live proof were removed before handoff.pnpm tsgo:core/extensions, lint, contract tests, andknip --dependencies(the deadcode gate) pass against this commit. CI artifacts on this PR.What was not tested: Clipboard-paste DM image handling remains a pre-existing limitation and is tracked as a separate follow-up. Full productized SSO/tool integration remains follow-up work; the SDK sign-in route and token persistence path itself is live-proven here.