@open-everywhere/provider-teams 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +46 -2
  4. package/agent/channels/teams-bot-name.ts +162 -0
  5. package/agent/channels/teams-catch-up.ts +244 -0
  6. package/agent/channels/teams-connector.ts +183 -0
  7. package/agent/channels/teams-conversation-ref.ts +398 -0
  8. package/agent/channels/teams-directory.ts +193 -0
  9. package/agent/channels/teams-engagement.ts +391 -0
  10. package/agent/channels/teams-fire-eligibility.ts +152 -0
  11. package/agent/channels/teams-front.ts +1404 -0
  12. package/agent/channels/teams-graph-boundary.ts +178 -0
  13. package/agent/channels/teams-graph-team-id.ts +106 -0
  14. package/agent/channels/teams-id-restore.ts +131 -0
  15. package/agent/channels/teams-identity.ts +395 -0
  16. package/agent/channels/teams-input-response.ts +129 -0
  17. package/agent/channels/teams-message-ops.ts +79 -0
  18. package/agent/channels/teams-origin-codec.ts +104 -0
  19. package/agent/channels/teams-post-arm.ts +323 -0
  20. package/agent/channels/teams-reaction-feedback.ts +127 -0
  21. package/agent/channels/teams-read.ts +510 -0
  22. package/agent/channels/teams-roster.ts +136 -0
  23. package/agent/channels/teams-stop.ts +319 -0
  24. package/agent/channels/teams-supervisor.ts +638 -0
  25. package/agent/channels/teams.ts +270 -0
  26. package/agent/contributions.ts +31 -0
  27. package/agent/docs-generated.ts +15 -0
  28. package/agent/ops.ts +129 -0
  29. package/agent/provider-events.ts +69 -0
  30. package/agent/register.ts +219 -0
  31. package/agent/surface-parity.ts +112 -0
  32. package/dist/agent/channels/teams-bot-name.d.ts +36 -0
  33. package/dist/agent/channels/teams-bot-name.d.ts.map +1 -0
  34. package/dist/agent/channels/teams-bot-name.js +141 -0
  35. package/dist/agent/channels/teams-bot-name.js.map +1 -0
  36. package/dist/agent/channels/teams-catch-up.d.ts +53 -0
  37. package/dist/agent/channels/teams-catch-up.d.ts.map +1 -0
  38. package/dist/agent/channels/teams-catch-up.js +221 -0
  39. package/dist/agent/channels/teams-catch-up.js.map +1 -0
  40. package/dist/agent/channels/teams-connector.d.ts +57 -0
  41. package/dist/agent/channels/teams-connector.d.ts.map +1 -0
  42. package/dist/agent/channels/teams-connector.js +132 -0
  43. package/dist/agent/channels/teams-connector.js.map +1 -0
  44. package/dist/agent/channels/teams-conversation-ref.d.ts +123 -0
  45. package/dist/agent/channels/teams-conversation-ref.d.ts.map +1 -0
  46. package/dist/agent/channels/teams-conversation-ref.js +368 -0
  47. package/dist/agent/channels/teams-conversation-ref.js.map +1 -0
  48. package/dist/agent/channels/teams-directory.d.ts +11 -0
  49. package/dist/agent/channels/teams-directory.d.ts.map +1 -0
  50. package/dist/agent/channels/teams-directory.js +179 -0
  51. package/dist/agent/channels/teams-directory.js.map +1 -0
  52. package/dist/agent/channels/teams-engagement.d.ts +174 -0
  53. package/dist/agent/channels/teams-engagement.d.ts.map +1 -0
  54. package/dist/agent/channels/teams-engagement.js +336 -0
  55. package/dist/agent/channels/teams-engagement.js.map +1 -0
  56. package/dist/agent/channels/teams-fire-eligibility.d.ts +26 -0
  57. package/dist/agent/channels/teams-fire-eligibility.d.ts.map +1 -0
  58. package/dist/agent/channels/teams-fire-eligibility.js +126 -0
  59. package/dist/agent/channels/teams-fire-eligibility.js.map +1 -0
  60. package/dist/agent/channels/teams-front.d.ts +249 -0
  61. package/dist/agent/channels/teams-front.d.ts.map +1 -0
  62. package/dist/agent/channels/teams-front.js +1143 -0
  63. package/dist/agent/channels/teams-front.js.map +1 -0
  64. package/dist/agent/channels/teams-graph-boundary.d.ts +45 -0
  65. package/dist/agent/channels/teams-graph-boundary.d.ts.map +1 -0
  66. package/dist/agent/channels/teams-graph-boundary.js +168 -0
  67. package/dist/agent/channels/teams-graph-boundary.js.map +1 -0
  68. package/dist/agent/channels/teams-graph-team-id.d.ts +30 -0
  69. package/dist/agent/channels/teams-graph-team-id.d.ts.map +1 -0
  70. package/dist/agent/channels/teams-graph-team-id.js +89 -0
  71. package/dist/agent/channels/teams-graph-team-id.js.map +1 -0
  72. package/dist/agent/channels/teams-id-restore.d.ts +34 -0
  73. package/dist/agent/channels/teams-id-restore.d.ts.map +1 -0
  74. package/dist/agent/channels/teams-id-restore.js +123 -0
  75. package/dist/agent/channels/teams-id-restore.js.map +1 -0
  76. package/dist/agent/channels/teams-identity.d.ts +148 -0
  77. package/dist/agent/channels/teams-identity.d.ts.map +1 -0
  78. package/dist/agent/channels/teams-identity.js +336 -0
  79. package/dist/agent/channels/teams-identity.js.map +1 -0
  80. package/dist/agent/channels/teams-input-response.d.ts +23 -0
  81. package/dist/agent/channels/teams-input-response.d.ts.map +1 -0
  82. package/dist/agent/channels/teams-input-response.js +102 -0
  83. package/dist/agent/channels/teams-input-response.js.map +1 -0
  84. package/dist/agent/channels/teams-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/teams-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/teams-message-ops.js +72 -0
  87. package/dist/agent/channels/teams-message-ops.js.map +1 -0
  88. package/dist/agent/channels/teams-origin-codec.d.ts +16 -0
  89. package/dist/agent/channels/teams-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/teams-origin-codec.js +99 -0
  91. package/dist/agent/channels/teams-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/teams-post-arm.d.ts +37 -0
  93. package/dist/agent/channels/teams-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/teams-post-arm.js +280 -0
  95. package/dist/agent/channels/teams-post-arm.js.map +1 -0
  96. package/dist/agent/channels/teams-reaction-feedback.d.ts +22 -0
  97. package/dist/agent/channels/teams-reaction-feedback.d.ts.map +1 -0
  98. package/dist/agent/channels/teams-reaction-feedback.js +108 -0
  99. package/dist/agent/channels/teams-reaction-feedback.js.map +1 -0
  100. package/dist/agent/channels/teams-read.d.ts +127 -0
  101. package/dist/agent/channels/teams-read.d.ts.map +1 -0
  102. package/dist/agent/channels/teams-read.js +424 -0
  103. package/dist/agent/channels/teams-read.js.map +1 -0
  104. package/dist/agent/channels/teams-roster.d.ts +54 -0
  105. package/dist/agent/channels/teams-roster.d.ts.map +1 -0
  106. package/dist/agent/channels/teams-roster.js +121 -0
  107. package/dist/agent/channels/teams-roster.js.map +1 -0
  108. package/dist/agent/channels/teams-stop.d.ts +74 -0
  109. package/dist/agent/channels/teams-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/teams-stop.js +252 -0
  111. package/dist/agent/channels/teams-stop.js.map +1 -0
  112. package/dist/agent/channels/teams-supervisor.d.ts +351 -0
  113. package/dist/agent/channels/teams-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/teams-supervisor.js +366 -0
  115. package/dist/agent/channels/teams-supervisor.js.map +1 -0
  116. package/dist/agent/channels/teams.d.ts +5 -0
  117. package/dist/agent/channels/teams.d.ts.map +1 -0
  118. package/dist/agent/channels/teams.js +246 -0
  119. package/dist/agent/channels/teams.js.map +1 -0
  120. package/dist/agent/contributions.d.ts +12 -0
  121. package/dist/agent/contributions.d.ts.map +1 -0
  122. package/dist/agent/contributions.js +31 -0
  123. package/dist/agent/contributions.js.map +1 -0
  124. package/dist/agent/docs-generated.d.ts +3 -0
  125. package/dist/agent/docs-generated.d.ts.map +1 -0
  126. package/dist/agent/docs-generated.js +13 -0
  127. package/dist/agent/docs-generated.js.map +1 -0
  128. package/dist/agent/ops.d.ts +14 -0
  129. package/dist/agent/ops.d.ts.map +1 -0
  130. package/dist/agent/ops.js +122 -0
  131. package/dist/agent/ops.js.map +1 -0
  132. package/dist/agent/provider-events.d.ts +3 -0
  133. package/dist/agent/provider-events.d.ts.map +1 -0
  134. package/dist/agent/provider-events.js +67 -0
  135. package/dist/agent/provider-events.js.map +1 -0
  136. package/dist/agent/register.d.ts +16 -0
  137. package/dist/agent/register.d.ts.map +1 -0
  138. package/dist/agent/register.js +198 -0
  139. package/dist/agent/register.js.map +1 -0
  140. package/dist/agent/surface-parity.d.ts +3 -0
  141. package/dist/agent/surface-parity.d.ts.map +1 -0
  142. package/dist/agent/surface-parity.js +106 -0
  143. package/dist/agent/surface-parity.js.map +1 -0
  144. package/package.json +183 -4
  145. package/tsconfig.build.json +22 -0
  146. package/tsconfig.json +15 -0
@@ -0,0 +1,319 @@
1
+ // THE TEAMS STOP ARM (OPE-898) — the reaction affordance, on the one glyph vocabulary Teams has.
2
+ //
3
+ // A person watching the agent do the wrong thing must be able to stop it, on every surface. Slack
4
+ // and Discord reach that through a ✋ reaction (OPE-418). Teams delivers reactions too, so the arm is
5
+ // event-driven here as well — but the VENDOR CONSTRAINTS are real and they shape the design, so read
6
+ // them before changing anything in this file.
7
+ //
8
+ // 🔴 THIS FILE IS ALSO THE SURFACE'S ONE REACTION ENTRY POINT. The front acks every
9
+ // `messageReaction` activity here and forwards none, so the FEEDBACK arm
10
+ // (channels/teams-reaction-feedback.ts, OPE-1502) hangs off `routeTeamsStopReaction` rather than off
11
+ // a second ingress. The fan-out comment on that function says how the two arms partition.
12
+ //
13
+ // ── WHAT MICROSOFT ACTUALLY DELIVERS (learn.microsoft.com, "Conversation events", read 2026-08-20)
14
+ //
15
+ // 1. The activity is `type: "messageReaction"`, with `reactionsAdded` / `reactionsRemoved`.
16
+ // 2. 🔴 THE REACTION VOCABULARY IS SIX FIXED VALUES: `angry`, `heart`, `laugh`, `like`, `sad`,
17
+ // `surprised`. There is no ✋, no custom emoji and no shortcode. So the glyph Slack and Discord
18
+ // use cannot be the glyph here, and picking one of the six is not a style choice — it is the
19
+ // only choice the platform offers.
20
+ // 3. 🔴 TEAMS SENDS THE EVENT ONLY FOR MESSAGES THE AGENT ITSELF SENT ("a user adds or removes
21
+ // reactions to a message, which was sent by your agent"). That is a FENCE, not a limitation: a
22
+ // reaction on somebody else's message can never reach this arm at all.
23
+ // 4. `replyToId` names the agent's own message. It is NOT the turn's anchor message — the anchor is
24
+ // the human's triggering message — so this arm never resolves by `anchorKey`. It resolves by the
25
+ // CONVERSATION, which is the `/oe stop` grain: the newest running turn in this room.
26
+ // 5. The event carries no message content, so nothing here reads or needs any.
27
+ //
28
+ // ── WHY `angry`
29
+ //
30
+ // Of the six, `angry` is the one people already use to mean "this is wrong". The false-stop window is
31
+ // narrow by construction, and every side of it is a fence Teams or the registry already enforces: the
32
+ // reaction must be on the AGENT's own message (3), in a room with a turn that is RUNNING RIGHT NOW
33
+ // (the anchor registry's 45-minute TTL and its live-turn check), and from somebody who is not the
34
+ // agent (the fail-closed guard below). A person who angry-reacts an agent message while the agent is
35
+ // working is saying the thing the affordance is for.
36
+ //
37
+ // The value is a named constant so it can be changed in one place if the vendor widens the
38
+ // vocabulary — which is the ONE upstream change that would let this surface use ✋ like the others.
39
+ //
40
+ // ⚠️ THE ARM SITS ABOVE THE EXTERNAL-PARTICIPANT GATE IN THE FRONT, AND THAT IS DELIBERATE. A cancel
41
+ // only ever takes capability away — it can start nothing, spend nothing and read nothing — so a
42
+ // person the policy would not let WAKE the agent may still stop it. That is the same posture the
43
+ // Slack and Discord arms have carried since OPE-418, and treating stop as a privilege would put the
44
+ // control furthest from the people most likely to need it.
45
+
46
+ import {
47
+ cancelAnchoredTurn,
48
+ resolvePartition,
49
+ turnChannelKey,
50
+ type AttachSessionForCancel,
51
+ type CancelOutcome,
52
+ } from "@open-everywhere/core/agent/adapter-api.js";
53
+ import { buildTeamsAuth } from "./teams-identity.js";
54
+ import { liveTeamsReactionFeedbackDeps, routeTeamsReactionFeedback, type TeamsReactionFeedbackDeps } from "./teams-reaction-feedback.js";
55
+
56
+ /** The Bot Framework reaction type this surface treats as "stop". See the header for why it is not
57
+ * ✋ and why it is this one of the six. */
58
+ export const TEAMS_STOP_REACTION = "angry";
59
+
60
+ /** One Teams `messageReaction` activity, narrowed to what the two reaction arms need. */
61
+ export interface TeamsReactionActivity {
62
+ /** The reaction types ADDED by this activity. A removal never stops — see {@link routeTeamsStopReaction}. */
63
+ readonly added: readonly string[];
64
+ /** The reaction types REMOVED by this activity. The stop arm ignores them; the feedback arm
65
+ * (channels/teams-reaction-feedback.ts) records them as retractions. */
66
+ readonly removed: readonly string[];
67
+ /** The AGENT's own message the reaction landed on (the activity's `replyToId`). Teams delivers a
68
+ * `messageReaction` only for messages the agent sent, so this names the feedback target. It is
69
+ * NOT the turn's anchor — see the `query` comment in {@link routeTeamsStopReaction}. */
70
+ readonly replyToId?: string;
71
+ /** WHO reacted (the activity's `from.id`). */
72
+ readonly reactorId?: string;
73
+ /** The AGENT's own Bot Framework id (the activity's `recipient.id`) — the fail-closed guard. */
74
+ readonly agentId?: string;
75
+ /** The room. On a channel activity this is the REPLY CHAIN id, which is what the stop registry
76
+ * keys a Teams turn by (see teams-identity.ts `anchorChannelId`). */
77
+ readonly conversationId?: string;
78
+ readonly tenantId?: string;
79
+ readonly teamsChannelId?: string;
80
+ readonly teamId?: string;
81
+ readonly scope?: string;
82
+ }
83
+
84
+ /** Why the arm did what it did — for the caller's log, never for a person to read. */
85
+ export type TeamsStopOutcome = (
86
+ | { readonly stopped: false; readonly reason: string }
87
+ | { readonly stopped: true; readonly outcome: CancelOutcome }
88
+ ) & {
89
+ /** The FEEDBACK write this activity started, present only when it carried a non-stop reaction
90
+ * (OPE-1502). It is SETTLED on every path the arm returns from, the cancel included (OPE-1546) —
91
+ * unless it outran {@link TEAMS_FEEDBACK_SETTLE_TIMEOUT_MS}, which is the one case a caller that
92
+ * ignores it can lose a row. It never rejects.
93
+ *
94
+ * It rides the outcome so a test can name the write it is asserting on, and so a future caller
95
+ * that wants to observe the row can. It is NOT a lifetime lever any more: the arm holds its own
96
+ * promise open until the write lands, which is what keeps the row inside the host's `waitUntil`
97
+ * lifetime without the front knowing this arm has a second consumer at all. */
98
+ readonly feedback?: Promise<void>;
99
+ };
100
+
101
+ function isObject(v: unknown): v is Record<string, unknown> {
102
+ return v !== null && typeof v === "object" && !Array.isArray(v);
103
+ }
104
+
105
+ function str(v: unknown): string | undefined {
106
+ return typeof v === "string" && v.length > 0 ? v : undefined;
107
+ }
108
+
109
+ function pick(o: unknown, k: string): unknown {
110
+ return isObject(o) ? o[k] : undefined;
111
+ }
112
+
113
+ /** Every `type` string in a `reactionsAdded` array, defensively. A malformed member contributes
114
+ * nothing rather than throwing — an ingress must not 500 on a body it does not recognise. */
115
+ function reactionTypes(v: unknown): string[] {
116
+ if (!Array.isArray(v)) return [];
117
+ const out: string[] = [];
118
+ for (const member of v) {
119
+ const t = str(pick(member, "type"));
120
+ if (t) out.push(t.toLowerCase());
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Parse one Bot Framework activity into a {@link TeamsReactionActivity}, or null when it is not a
127
+ * reaction activity. NEVER THROWS.
128
+ *
129
+ * This reads the RAW parsed body rather than eve's `parseTeamsActivity`, because that parser answers
130
+ * only for `message` activities — a `messageReaction` comes back as null from it, which is exactly
131
+ * why the front forwards one to the native channel today and nothing happens.
132
+ */
133
+ export function parseTeamsReaction(parsed: unknown): TeamsReactionActivity | null {
134
+ if (!isObject(parsed)) return null;
135
+ if (parsed["type"] !== "messageReaction") return null;
136
+ const conversation = parsed["conversation"];
137
+ const channelData = parsed["channelData"];
138
+ return {
139
+ added: reactionTypes(parsed["reactionsAdded"]),
140
+ removed: reactionTypes(parsed["reactionsRemoved"]),
141
+ ...(str(parsed["replyToId"]) ? { replyToId: str(parsed["replyToId"])! } : {}),
142
+ reactorId: str(pick(parsed["from"], "id")),
143
+ agentId: str(pick(parsed["recipient"], "id")),
144
+ conversationId: str(pick(conversation, "id")),
145
+ tenantId: str(parsed["tenantId"]) ?? str(pick(conversation, "tenantId")) ?? str(pick(pick(channelData, "tenant"), "id")),
146
+ teamsChannelId: str(pick(pick(channelData, "channel"), "id")),
147
+ teamId: str(pick(pick(channelData, "team"), "id")),
148
+ scope: str(pick(conversation, "conversationType")),
149
+ };
150
+ }
151
+
152
+ /** Injected seams, so parse + guard + cancel is unit-tested without a Bot registration. */
153
+ export interface TeamsStopDeps {
154
+ readonly attachSession: AttachSessionForCancel;
155
+ /** Injectable for the test; defaults to the shared `cancelAnchoredTurn`. There is no second cancel
156
+ * implementation behind this — the seam exists to observe the call, never to replace it. */
157
+ readonly cancel?: typeof cancelAnchoredTurn;
158
+ /** The FEEDBACK arm's sinks (OPE-1502), injectable for the same reason. Defaults to the live
159
+ * audit/telemetry sink. See {@link routeTeamsStopReaction} for why the fan-out lives here. */
160
+ readonly feedback?: TeamsReactionFeedbackDeps;
161
+ }
162
+
163
+ /** What {@link stopGuard} decided about one activity. */
164
+ type TeamsStopGuard =
165
+ | { readonly stop: false; readonly reason: string }
166
+ /** The two ids the cancel needs, POSITIVELY RESOLVED — never re-read off the activity. */
167
+ | { readonly stop: true; readonly reactorId: string; readonly conversationId: string };
168
+
169
+ /**
170
+ * Decide whether one activity is a stop, and say why not when it is not.
171
+ *
172
+ * 🔴 PURE, AND THAT IS WHY IT EXISTS. {@link routeTeamsStopReaction} must know whether a cancel is
173
+ * coming BEFORE it starts the feedback write, so that the write can be awaited on every path that
174
+ * cancels nothing and fired-and-forgotten on the one path where a person is waiting. Five inline
175
+ * early returns cannot answer that question ahead of time.
176
+ *
177
+ * 🔴 THE REACTOR GUARD FAILS CLOSED. The agent's own reactions ride the same webhook, and behind
178
+ * `cancel()` a fail-OPEN identity check is a self-cancel. A stop therefore needs a POSITIVELY
179
+ * RESOLVED agent id and a reactor who is not it. `28:` is the Bot Framework's own prefix for an
180
+ * application id, so a reactor wearing it is a bot whatever the recipient field says.
181
+ */
182
+ function stopGuard(rx: TeamsReactionActivity): TeamsStopGuard {
183
+ // ONLY ON ADD, and only on `TEAMS_STOP_REACTION`. A removal means nothing and must never trigger
184
+ // anything — the same rule the Slack, Discord and Linear arms keep.
185
+ if (!rx.added.includes(TEAMS_STOP_REACTION)) return { stop: false, reason: "stop: not the stop reaction" };
186
+ if (!rx.reactorId) return { stop: false, reason: "stop: no reactor id — refusing to cancel (fail-closed)" };
187
+ if (!rx.agentId) return { stop: false, reason: "stop: agent identity unresolved — refusing to cancel (fail-closed)" };
188
+ if (rx.reactorId === rx.agentId || rx.reactorId.startsWith("28:")) {
189
+ return { stop: false, reason: "stop: the reactor is a bot — never a self-cancel" };
190
+ }
191
+ if (!rx.conversationId) return { stop: false, reason: "stop: no conversation — nothing to resolve a turn against" };
192
+ return { stop: true, reactorId: rx.reactorId, conversationId: rx.conversationId };
193
+ }
194
+
195
+ /** Start the FEEDBACK write for one activity's non-stop reactions. NEVER REJECTS: a feedback row is
196
+ * best-effort on every path, so its failure is swallowed here rather than by each caller. */
197
+ async function writeTeamsReactionFeedback(feedback: TeamsReactionActivity, deps: TeamsStopDeps): Promise<void> {
198
+ await routeTeamsReactionFeedback(feedback, deps.feedback ?? liveTeamsReactionFeedbackDeps()).catch(() => undefined);
199
+ }
200
+
201
+ /** How long this arm holds its own promise open for the feedback write. See {@link settleFeedback}. */
202
+ export const TEAMS_FEEDBACK_SETTLE_TIMEOUT_MS = 5000;
203
+
204
+ /** Wait for the feedback write to settle, with a ceiling.
205
+ *
206
+ * The three call sites below await this promise so the row survives the front's `waitUntil` — see
207
+ * the fan-out block. 🔴 THE CEILING IS WHAT KEEPS THAT FROM BECOMING A LEAK: the awaits extend the
208
+ * `waitUntil` promise by however long the audit sink takes, which unbounded is however long a stuck
209
+ * store takes. Past the ceiling the write is abandoned, not cancelled, and may still land — the same
210
+ * trade the row already made by being best-effort. The write itself never rejects, so nothing is
211
+ * left unhandled behind the race.
212
+ *
213
+ * `teams-stop-test.ts` §7 pins this against a sink that never settles. Without that case the ceiling
214
+ * is asserted by this comment alone, and a refactor that put a bare `await pending` back would pass
215
+ * every other assertion in the suite. */
216
+ async function settleFeedback(pending: Promise<void> | undefined): Promise<void> {
217
+ if (!pending) return;
218
+ let timer: ReturnType<typeof setTimeout> | undefined;
219
+ const ceiling = new Promise<void>((resolve) => {
220
+ timer = setTimeout(resolve, TEAMS_FEEDBACK_SETTLE_TIMEOUT_MS);
221
+ timer.unref?.();
222
+ });
223
+ try {
224
+ await Promise.race([pending, ceiling]);
225
+ } finally {
226
+ // The ordinary path settles the write long before the ceiling; without this the timer would sit
227
+ // in the loop for the rest of its five seconds on every reaction.
228
+ clearTimeout(timer);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Route one Teams reaction activity. Cancels the turn running in this conversation, or reports why
234
+ * not. {@link stopGuard} holds the guards, the fail-closed rule among them.
235
+ */
236
+ export async function routeTeamsStopReaction(rx: TeamsReactionActivity, deps: TeamsStopDeps): Promise<TeamsStopOutcome> {
237
+ // ── THE FEEDBACK FAN-OUT (OPE-1502) ────────────────────────────────────────────────────────────
238
+ //
239
+ // POSITION IS LOAD-BEARING, and so is the fact that it sits in THIS function rather than in the
240
+ // front. The front routes EVERY `messageReaction` activity here and acks — it never forwards one —
241
+ // so this is the one reachable point a second consumer can attach to.
242
+ //
243
+ // 🔴 THE SPLIT IS PER REACTION, NOT PER ACTIVITY. `reactionsAdded` and `reactionsRemoved` are
244
+ // ARRAYS, and one activity can carry an added `angry` beside an added `like`. Only the added
245
+ // `angry` is the MECHANISM; the rest of that same activity is a person's sentiment and must still
246
+ // be recorded. Partitioning on the whole activity — "does `added` contain the stop glyph" — would
247
+ // drop the `like` on the floor. A REMOVED stop glyph is not a stop at all, so it stays feedback,
248
+ // which is the same rule the Slack and Discord arms keep for ✋.
249
+ //
250
+ // 🔴 THE GUARDS DECIDE FIRST, SO A CANCEL IS NEVER HELD BEHIND THE WRITE. A lost OR SLOW feedback
251
+ // row must never cost a person their stop, and `.catch()` answers only the lost half — an awaited
252
+ // write in FRONT of the guards holds the cancel for exactly as long as the audit sink takes, and
253
+ // that sink is the slowest thing this arm touches. So the arm asks `stopGuard` whether a cancel is
254
+ // coming BEFORE it starts the write, and nothing is awaited between starting the write and calling
255
+ // `cancel()`. The two then run concurrently and the cancel always lands first.
256
+ //
257
+ // 🔴 THE ARM AWAITS THE WRITE AFTER THE CANCEL, WHICH IS WHAT MAKES THE ROW SURVIVE (OPE-1546).
258
+ // The front hands THIS function's promise to `waitUntil` and awaits no further, so whatever is
259
+ // still in flight when this promise resolves is at the mercy of a suspending host. Returning with
260
+ // the write unfinished therefore lost rows on the one path that has a second thing to do — a mixed
261
+ // `angry`+`like` activity could lose the `like`, and a two-reaction activity its second row. The
262
+ // close is one `await` here rather than a hook in the front: the write is already started, the
263
+ // cancel has already happened, and holding this promise open costs the stop nothing that a person
264
+ // can observe, because the ingress acked before any of this ran. Every path now returns with the
265
+ // row settled, which is why `TeamsStopOutcome.feedback` no longer states a trade.
266
+ //
267
+ // 🔴 THE HOLD IS BOUNDED. "Holding this promise open" is holding the front's `waitUntil` open, and
268
+ // the thing being waited on is the slowest store this arm touches. The ingress acked before any of
269
+ // this ran, so a person waits on none of it — but a stuck sink would hold the host's `waitUntil`
270
+ // lifetime open with no limit, which is a leak rather than a survival trick. `settleFeedback` puts
271
+ // a ceiling on it.
272
+ const feedback: TeamsReactionActivity = { ...rx, added: rx.added.filter((type) => type !== TEAMS_STOP_REACTION) };
273
+ const carries = feedback.added.length > 0 || feedback.removed.length > 0;
274
+ const guard = stopGuard(rx);
275
+ const pending = carries ? writeTeamsReactionFeedback(feedback, deps) : undefined;
276
+ const rider = pending ? { feedback: pending } : {};
277
+
278
+ if (!guard.stop) {
279
+ await settleFeedback(pending);
280
+ return { stopped: false, reason: guard.reason, ...rider };
281
+ }
282
+
283
+ const partition = resolvePartition({
284
+ channel: { kind: "teams" },
285
+ session: {
286
+ auth: {
287
+ current: buildTeamsAuth({
288
+ userId: guard.reactorId,
289
+ conversationId: guard.conversationId,
290
+ ...(rx.tenantId ? { tenantId: rx.tenantId } : {}),
291
+ ...(rx.teamsChannelId ? { channelId: rx.teamsChannelId } : {}),
292
+ ...(rx.teamId ? { teamId: rx.teamId } : {}),
293
+ ...(rx.scope ? { scope: rx.scope } : {}),
294
+ }),
295
+ },
296
+ },
297
+ });
298
+ const channelKey = turnChannelKey("teams", guard.conversationId);
299
+ if (!channelKey) {
300
+ // Still a path that cancels nothing, so the write is settled here like the refusals above.
301
+ await settleFeedback(pending);
302
+ return { stopped: false, reason: "stop: no conversation key", ...rider };
303
+ }
304
+ const result = await (deps.cancel ?? cancelAnchoredTurn)({
305
+ org: partition.org,
306
+ platform: "teams",
307
+ workspace: partition.workspace,
308
+ requestedBy: guard.reactorId,
309
+ via: "reaction",
310
+ // THE CONVERSATION KEY ONLY. `replyToId` names the AGENT's message, and the anchor registry keys
311
+ // on the human's triggering message — the two never match, so offering an `anchorKey` here would
312
+ // be a lookup that always misses. This is the `/oe stop` grain: the newest running turn here.
313
+ query: { channelKey },
314
+ attachSession: deps.attachSession,
315
+ });
316
+ // AFTER the cancel, never before it — see the fan-out block above. This is the whole of OPE-1546.
317
+ await settleFeedback(pending);
318
+ return { stopped: true, outcome: result.outcome, ...rider };
319
+ }