@app-vitals/agent-runtime 0.0.1 → 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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +38 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,1070 @@
1
+ /**
2
+ * Unit tests for pure helpers in agent/src/runtime/slack.ts.
3
+ *
4
+ * hasSlackCredentials gates whether the agent boots its Slack Bolt App. Bolt's
5
+ * Socket Mode throws "Must provide an App-Level Token" when constructed without
6
+ * a non-empty appToken, so the agent must only call createSlackApp when both the
7
+ * bot token and the app-level token are present. Absent creds → offline mode.
8
+ *
9
+ * The isAllowedSlackSender + handler-level membership-gating tests below cover
10
+ * SMR-4.1: a single shared gate, called at the top of message/app_mention/
11
+ * reaction_added, before runner() is invoked. This file builds its own minimal
12
+ * MockApp harness (mirroring slack.integration.test.ts's pattern) so these
13
+ * cases are self-contained and slack.integration.test.ts stays untouched —
14
+ * its existing "processed as today" assertions continue to exercise the same
15
+ * default fail-open code path (unsynced ref / restrict=false).
16
+ */
17
+
18
+ import { describe, expect, mock, test } from "bun:test";
19
+ import type { AgentSlackMembershipRef } from "./agent-slack-membership-ref.ts";
20
+ import { createAgentSlackMembershipRef } from "./agent-slack-membership-ref.ts";
21
+ import {
22
+ createSlackApp as _createSlackApp,
23
+ dispatchMarkers,
24
+ hasSlackCredentials,
25
+ isAllowedSlackSender,
26
+ } from "./slack.ts";
27
+
28
+ describe("hasSlackCredentials", () => {
29
+ test("true when both bot and app tokens are non-empty", () => {
30
+ expect(
31
+ hasSlackCredentials({ botToken: "xoxb-1", appToken: "xapp-1" }),
32
+ ).toBe(true);
33
+ });
34
+
35
+ test("false when app token is missing (the Socket Mode requirement)", () => {
36
+ expect(hasSlackCredentials({ botToken: "xoxb-1", appToken: "" })).toBe(
37
+ false,
38
+ );
39
+ });
40
+
41
+ test("false when bot token is missing", () => {
42
+ expect(hasSlackCredentials({ botToken: "", appToken: "xapp-1" })).toBe(
43
+ false,
44
+ );
45
+ });
46
+
47
+ test("false when both are empty (offline dev default)", () => {
48
+ expect(hasSlackCredentials({ botToken: "", appToken: "" })).toBe(false);
49
+ });
50
+
51
+ test("treats whitespace-only tokens as absent", () => {
52
+ expect(hasSlackCredentials({ botToken: " ", appToken: " " })).toBe(false);
53
+ });
54
+ });
55
+
56
+ // ─── dispatchMarkers — plan marker ──────────────────────────────────────────
57
+ // Injects a plain-object Slack client double (no mock.module, no global
58
+ // override) and asserts the [plan:url] marker posts a "View plan" message to
59
+ // the bound channel/thread.
60
+
61
+ describe("dispatchMarkers — plan marker", () => {
62
+ test("posts a View plan message to the bound channel/thread", async () => {
63
+ // biome-ignore lint/suspicious/noExplicitAny: test double captures calls
64
+ const calls: any[] = [];
65
+ const client = {
66
+ chat: {
67
+ // biome-ignore lint/suspicious/noExplicitAny: test double
68
+ postMessage: async (a: any) => {
69
+ calls.push(a);
70
+ return { ok: true };
71
+ },
72
+ },
73
+ };
74
+
75
+ await dispatchMarkers(
76
+ [{ type: "plan", url: "https://example.com/p/abc" }],
77
+ { client, channel: "C123", threadTs: "1700.1" },
78
+ );
79
+
80
+ expect(calls).toHaveLength(1);
81
+ const call = calls[0];
82
+ expect(call.channel).toBe("C123");
83
+ expect(call.thread_ts).toBe("1700.1");
84
+ expect(JSON.stringify(call.blocks)).toContain("https://example.com/p/abc");
85
+ expect(call.text).toContain("https://example.com/p/abc");
86
+ });
87
+ });
88
+
89
+ // ─── isAllowedSlackSender ───────────────────────────────────────────────────
90
+
91
+ describe("isAllowedSlackSender", () => {
92
+ test("allows when the membership ref has never synced, regardless of email", () => {
93
+ const ref = createAgentSlackMembershipRef();
94
+ expect(isAllowedSlackSender(undefined, ref)).toBe(true);
95
+ expect(isAllowedSlackSender("nobody@example.com", ref)).toBe(true);
96
+ });
97
+
98
+ test("allows when synced but restrict is false", () => {
99
+ const ref = createAgentSlackMembershipRef();
100
+ ref.set({ restrict: false, emails: ["dan@example.com"] });
101
+ expect(isAllowedSlackSender(undefined, ref)).toBe(true);
102
+ expect(isAllowedSlackSender("someone-else@example.com", ref)).toBe(true);
103
+ });
104
+
105
+ test("allows a matching email (exact case)", () => {
106
+ const ref = createAgentSlackMembershipRef();
107
+ ref.set({ restrict: true, emails: ["dan@example.com"] });
108
+ expect(isAllowedSlackSender("dan@example.com", ref)).toBe(true);
109
+ });
110
+
111
+ test("allows a matching email case-insensitively", () => {
112
+ const ref = createAgentSlackMembershipRef();
113
+ ref.set({ restrict: true, emails: ["Dan@Example.com"] });
114
+ expect(isAllowedSlackSender("dan@example.com", ref)).toBe(true);
115
+ expect(isAllowedSlackSender("DAN@EXAMPLE.COM", ref)).toBe(true);
116
+ });
117
+
118
+ test("rejects a non-matching email", () => {
119
+ const ref = createAgentSlackMembershipRef();
120
+ ref.set({ restrict: true, emails: ["dan@example.com"] });
121
+ expect(isAllowedSlackSender("stranger@example.com", ref)).toBe(false);
122
+ });
123
+
124
+ test("rejects an undefined email when restrict is true", () => {
125
+ const ref = createAgentSlackMembershipRef();
126
+ ref.set({ restrict: true, emails: ["dan@example.com"] });
127
+ expect(isAllowedSlackSender(undefined, ref)).toBe(false);
128
+ });
129
+ });
130
+
131
+ // ─── Handler-level membership gating ───────────────────────────────────────
132
+ // Self-contained MockApp harness (mirrors slack.integration.test.ts) scoped
133
+ // to this file — captures constructor args + registered handlers so the
134
+ // three gating call sites can be exercised without duplicating the 3000+
135
+ // lines of unrelated setup living in slack.integration.test.ts.
136
+
137
+ type HandlerFn = (...args: unknown[]) => Promise<void>;
138
+
139
+ let capturedMessageHandler: HandlerFn | null = null;
140
+ let capturedMentionHandler: HandlerFn | null = null;
141
+ let capturedReactionAddedHandler: HandlerFn | null = null;
142
+ let capturedAgentSessionStoppedHandler: HandlerFn | null = null;
143
+
144
+ class MockApp {
145
+ constructor(_args: Record<string, unknown>) {
146
+ capturedMessageHandler = null;
147
+ capturedMentionHandler = null;
148
+ capturedReactionAddedHandler = null;
149
+ capturedAgentSessionStoppedHandler = null;
150
+ }
151
+
152
+ message(handler: HandlerFn) {
153
+ capturedMessageHandler = handler;
154
+ }
155
+
156
+ event(type: string, handler: HandlerFn) {
157
+ if (type === "app_mention") capturedMentionHandler = handler;
158
+ if (type === "reaction_added") capturedReactionAddedHandler = handler;
159
+ if (type === "agent_session_stopped")
160
+ capturedAgentSessionStoppedHandler = handler;
161
+ }
162
+ }
163
+
164
+ const mockGatingSlackConfig = {
165
+ botToken: "xoxb-test-token",
166
+ appToken: "xapp-test-token",
167
+ signingSecret: "test-secret",
168
+ };
169
+
170
+ function makeMockClient() {
171
+ return {
172
+ agents: {
173
+ sessions: {
174
+ setStatus: mock(async (_args: unknown) => {}),
175
+ },
176
+ },
177
+ reactions: {
178
+ add: mock(async (_args: unknown) => {}),
179
+ },
180
+ files: {
181
+ uploadV2: mock(async (_args: unknown) => {}),
182
+ },
183
+ chat: {
184
+ postMessage: mock(async (_args: unknown) => ({ ts: "resp.ts.1" })),
185
+ startStream: mock(async (_args: unknown) => ({ ts: "stream.ts.1" })),
186
+ appendStream: mock(async (_args: unknown) => {}),
187
+ stopStream: mock(async (_args: unknown) => {}),
188
+ },
189
+ users: {
190
+ info: mock(async (_args: unknown) => ({
191
+ user: {
192
+ profile: { display_name: "Test User", email: "member@example.com" },
193
+ name: "testuser",
194
+ },
195
+ })),
196
+ },
197
+ };
198
+ }
199
+
200
+ function makeSay() {
201
+ return mock(async (_args: unknown) => ({ ts: "reply.ts.1" }));
202
+ }
203
+
204
+ function setupGatingApp(overrides: {
205
+ membershipRef: AgentSlackMembershipRef;
206
+ resolveUserEmailFn: (
207
+ userId: string,
208
+ client: unknown,
209
+ ) => Promise<string | undefined>;
210
+ runner?: (
211
+ message: string,
212
+ sessionKey?: string,
213
+ ) => Promise<{
214
+ result: string;
215
+ sessionId?: string;
216
+ streamIncomplete?: boolean;
217
+ }>;
218
+ getSessionFn?: (key: string) => Promise<string | undefined>;
219
+ }) {
220
+ const runner =
221
+ overrides.runner ??
222
+ mock(async (_msg: string, _key?: string) => ({
223
+ result: "Claude response",
224
+ sessionId: "sess-1",
225
+ }));
226
+
227
+ _createSlackApp(
228
+ runner,
229
+ (text: string) => text,
230
+ (channel: string, ts: string) => `${channel}:${ts}`,
231
+ // biome-ignore lint/suspicious/noExplicitAny: mock factory for tests
232
+ (cfg) => new MockApp(cfg as Record<string, unknown>) as any,
233
+ mockGatingSlackConfig,
234
+ undefined, // sentryClient — default noop
235
+ async () => null, // fileDownloaderFn
236
+ {}, // voiceConfig
237
+ async () => null, // transcribeAudioFn
238
+ async () => null, // synthesizeSpeechFn
239
+ async (userId: string) => userId, // resolveUserFn (display name) — identity
240
+ "UBOT123", // botUserId
241
+ async () => ({ messages: [] }), // conversationsRepliesFn
242
+ overrides.getSessionFn ?? (async () => undefined), // getSessionFn
243
+ undefined, // blocksConverter — default
244
+ undefined, // chatTokenReporter — default noop
245
+ overrides.resolveUserEmailFn,
246
+ overrides.membershipRef,
247
+ );
248
+
249
+ return { runner };
250
+ }
251
+
252
+ describe("membership gating — message handler", () => {
253
+ async function invokeDM(
254
+ membershipRef: AgentSlackMembershipRef,
255
+ resolveUserEmailFn: (
256
+ userId: string,
257
+ client: unknown,
258
+ ) => Promise<string | undefined>,
259
+ ) {
260
+ const { runner } = setupGatingApp({ membershipRef, resolveUserEmailFn });
261
+ const client = makeMockClient();
262
+ const say = makeSay();
263
+ const message = {
264
+ channel: "D123",
265
+ ts: "111.222",
266
+ text: "Hello bot",
267
+ channel_type: "im",
268
+ user: "U-SENDER",
269
+ };
270
+ await capturedMessageHandler?.({ message, say, client });
271
+ return { runner, say, client };
272
+ }
273
+
274
+ test("unsynced ref: processed as today (runner called)", async () => {
275
+ const ref = createAgentSlackMembershipRef();
276
+ const { runner, client } = await invokeDM(ref, async () => undefined);
277
+ expect(runner).toHaveBeenCalledTimes(1);
278
+ expect(client.chat.appendStream).toHaveBeenCalled();
279
+ });
280
+
281
+ test("restrict=false: processed as today (runner called)", async () => {
282
+ const ref = createAgentSlackMembershipRef();
283
+ ref.set({ restrict: false, emails: [] });
284
+ const { runner, client } = await invokeDM(ref, async () => undefined);
285
+ expect(runner).toHaveBeenCalledTimes(1);
286
+ expect(client.chat.appendStream).toHaveBeenCalled();
287
+ });
288
+
289
+ test("restrict=true + matching email: processed as today", async () => {
290
+ const ref = createAgentSlackMembershipRef();
291
+ ref.set({ restrict: true, emails: ["member@example.com"] });
292
+ const { runner, client } = await invokeDM(
293
+ ref,
294
+ async () => "member@example.com",
295
+ );
296
+ expect(runner).toHaveBeenCalledTimes(1);
297
+ expect(client.chat.appendStream).toHaveBeenCalled();
298
+ });
299
+
300
+ test("restrict=true + non-member email: runner not called, no reply posted", async () => {
301
+ const ref = createAgentSlackMembershipRef();
302
+ ref.set({ restrict: true, emails: ["member@example.com"] });
303
+ const { runner, say } = await invokeDM(
304
+ ref,
305
+ async () => "stranger@example.com",
306
+ );
307
+ expect(runner).not.toHaveBeenCalled();
308
+ expect(say).not.toHaveBeenCalled();
309
+ });
310
+
311
+ test("restrict=true + undefined email (resolution failed): runner not called", async () => {
312
+ const ref = createAgentSlackMembershipRef();
313
+ ref.set({ restrict: true, emails: ["member@example.com"] });
314
+ const { runner, say } = await invokeDM(ref, async () => undefined);
315
+ expect(runner).not.toHaveBeenCalled();
316
+ expect(say).not.toHaveBeenCalled();
317
+ });
318
+ });
319
+
320
+ describe("membership gating — app_mention handler", () => {
321
+ async function invokeMention(
322
+ membershipRef: AgentSlackMembershipRef,
323
+ resolveUserEmailFn: (
324
+ userId: string,
325
+ client: unknown,
326
+ ) => Promise<string | undefined>,
327
+ ) {
328
+ const { runner } = setupGatingApp({ membershipRef, resolveUserEmailFn });
329
+ const client = makeMockClient();
330
+ const say = makeSay();
331
+ const event = {
332
+ text: "<@UBOT> do something",
333
+ channel: "C999",
334
+ ts: "222.333",
335
+ user: "U-SENDER",
336
+ };
337
+ await capturedMentionHandler?.({ event, say, client });
338
+ return { runner, say, client };
339
+ }
340
+
341
+ test("unsynced ref: processed as today (runner called)", async () => {
342
+ const ref = createAgentSlackMembershipRef();
343
+ const { runner, client } = await invokeMention(ref, async () => undefined);
344
+ expect(runner).toHaveBeenCalledTimes(1);
345
+ expect(client.chat.appendStream).toHaveBeenCalled();
346
+ });
347
+
348
+ test("restrict=false: processed as today (runner called)", async () => {
349
+ const ref = createAgentSlackMembershipRef();
350
+ ref.set({ restrict: false, emails: [] });
351
+ const { runner, client } = await invokeMention(ref, async () => undefined);
352
+ expect(runner).toHaveBeenCalledTimes(1);
353
+ expect(client.chat.appendStream).toHaveBeenCalled();
354
+ });
355
+
356
+ test("restrict=true + matching email: processed as today", async () => {
357
+ const ref = createAgentSlackMembershipRef();
358
+ ref.set({ restrict: true, emails: ["member@example.com"] });
359
+ const { runner, client } = await invokeMention(
360
+ ref,
361
+ async () => "MEMBER@example.com",
362
+ );
363
+ expect(runner).toHaveBeenCalledTimes(1);
364
+ expect(client.chat.appendStream).toHaveBeenCalled();
365
+ });
366
+
367
+ test("restrict=true + non-member email: runner not called, no reply posted", async () => {
368
+ const ref = createAgentSlackMembershipRef();
369
+ ref.set({ restrict: true, emails: ["member@example.com"] });
370
+ const { runner, say } = await invokeMention(
371
+ ref,
372
+ async () => "stranger@example.com",
373
+ );
374
+ expect(runner).not.toHaveBeenCalled();
375
+ expect(say).not.toHaveBeenCalled();
376
+ });
377
+
378
+ test("restrict=true + undefined email (resolution failed): runner not called", async () => {
379
+ const ref = createAgentSlackMembershipRef();
380
+ ref.set({ restrict: true, emails: ["member@example.com"] });
381
+ const { runner, say } = await invokeMention(ref, async () => undefined);
382
+ expect(runner).not.toHaveBeenCalled();
383
+ expect(say).not.toHaveBeenCalled();
384
+ });
385
+ });
386
+
387
+ // ─── Per-thread Thinking Steps refcounting (SSF-1.1, streaming per STS2-1.1) ─
388
+ // A burst of overlapping message-handler invocations on the same thread each
389
+ // get their own independent chat.startStream/stopStream lifecycle — the
390
+ // ThreadStatusTracker refcount only selects the seed card's title
391
+ // ("Queued…" vs "Thinking…") and the session_status stopStream closes with
392
+ // ("processing" while other messages are still in flight, "active" only for
393
+ // the genuine last one). This exercises the tracker wired into
394
+ // createSlackApp via the same MockApp/mock-client harness used by the
395
+ // membership-gating suite above.
396
+
397
+ // Flushes pending microtask hops (getSessionFn / shouldRejectSlackSender's
398
+ // resolveUserEmailFn awaits, etc.) that run before a handler reaches
399
+ // progress.start(), so assertions made right after firing a handler call
400
+ // observe state as of "just before runner() is invoked" rather than mid-flight.
401
+ function flushMicrotasks(): Promise<void> {
402
+ return new Promise((resolve) => setTimeout(resolve, 0));
403
+ }
404
+
405
+ function seedTitles(client: ReturnType<typeof makeMockClient>): string[] {
406
+ return client.chat.appendStream.mock.calls
407
+ .map(
408
+ (c) =>
409
+ (c[0] as { chunks: Array<Record<string, unknown>> }).chunks.find(
410
+ (chunk) => chunk.type === "task_update",
411
+ )?.title,
412
+ )
413
+ .filter((t): t is string => typeof t === "string" && t.endsWith("…"));
414
+ }
415
+
416
+ function stopStreamStatuses(
417
+ client: ReturnType<typeof makeMockClient>,
418
+ ): string[] {
419
+ return client.chat.stopStream.mock.calls.map(
420
+ (c: unknown[]) => (c[0] as { session_status: string }).session_status,
421
+ );
422
+ }
423
+
424
+ describe("per-thread Thinking Steps refcounting — message handler burst", () => {
425
+ test("2 overlapping runs on the same thread: 'Thinking…' then 'Queued…' seeds, 'processing' then 'active' stops", async () => {
426
+ // Deferred promises let the test control exactly when each runner()
427
+ // invocation resolves. Keyed by the prompt text (which embeds the
428
+ // message body) rather than call order — resolveUserFn's await means the
429
+ // two concurrent handler invocations can reach runner() in either order,
430
+ // so the mock must resolve the correct in-flight call regardless of
431
+ // which one actually invoked runner() first.
432
+ let resolveFirst!: (value: { result: string; sessionId?: string }) => void;
433
+ let resolveSecond!: (value: { result: string; sessionId?: string }) => void;
434
+ const firstDone = new Promise<{ result: string; sessionId?: string }>(
435
+ (resolve) => {
436
+ resolveFirst = resolve;
437
+ },
438
+ );
439
+ const secondDone = new Promise<{ result: string; sessionId?: string }>(
440
+ (resolve) => {
441
+ resolveSecond = resolve;
442
+ },
443
+ );
444
+
445
+ const runner = mock(async (msg: string, _key?: string) =>
446
+ msg.includes("second message") ? secondDone : firstDone,
447
+ );
448
+
449
+ const ref = createAgentSlackMembershipRef();
450
+ setupGatingApp({
451
+ membershipRef: ref,
452
+ resolveUserEmailFn: async () => undefined,
453
+ runner,
454
+ });
455
+
456
+ const client = makeMockClient();
457
+ const say = makeSay();
458
+ // Same thread_ts on both messages — sessionKey is derived from
459
+ // (channel, thread_ts ?? ts), so a burst on one thread must share a
460
+ // thread_ts even though each individual message has its own ts.
461
+ const message = {
462
+ channel: "D123",
463
+ ts: "300.1",
464
+ thread_ts: "300.1",
465
+ text: "first message",
466
+ channel_type: "im",
467
+ user: "U-SENDER",
468
+ };
469
+
470
+ // Fire both handler invocations before either runner() call resolves —
471
+ // simulates a burst of 2 messages queued on the same thread while a run
472
+ // is in flight.
473
+ const firstHandlerCall = capturedMessageHandler?.({
474
+ message,
475
+ say,
476
+ client,
477
+ });
478
+ const secondHandlerCall = capturedMessageHandler?.({
479
+ message: { ...message, ts: "300.2", text: "second message" },
480
+ say,
481
+ client,
482
+ });
483
+
484
+ await flushMicrotasks();
485
+
486
+ // Both messages have already opened their own stream by now — the first
487
+ // seeded "Thinking…" (0->1 transition), the second "Queued…" (still 1
488
+ // in flight) — and neither handler has reached its finally block yet.
489
+ expect(client.chat.startStream).toHaveBeenCalledTimes(2);
490
+ expect(seedTitles(client)).toEqual(["Thinking…", "Queued…"]);
491
+ expect(client.chat.stopStream).not.toHaveBeenCalled();
492
+
493
+ // Resolve the FIRST run — since a second run is still in flight, this
494
+ // must close with session_status "processing", not "active" (the false
495
+ // "done" signal this refcount prevents).
496
+ resolveFirst({ result: "first done", sessionId: "sess-1" });
497
+ await firstHandlerCall;
498
+
499
+ expect(client.chat.stopStream).toHaveBeenCalledTimes(1);
500
+ expect(stopStreamStatuses(client)).toEqual(["processing"]);
501
+
502
+ // Resolve the SECOND (last) run — only now should stopStream close with
503
+ // "active".
504
+ resolveSecond({ result: "second done", sessionId: "sess-1" });
505
+ await secondHandlerCall;
506
+
507
+ expect(client.chat.stopStream).toHaveBeenCalledTimes(2);
508
+ expect(stopStreamStatuses(client)).toEqual(["processing", "active"]);
509
+ });
510
+
511
+ test("bursts on different threads do not interfere with each other's seed titles or stop statuses", async () => {
512
+ let resolveA!: (value: { result: string; sessionId?: string }) => void;
513
+ let resolveB!: (value: { result: string; sessionId?: string }) => void;
514
+ const doneA = new Promise<{ result: string; sessionId?: string }>(
515
+ (resolve) => {
516
+ resolveA = resolve;
517
+ },
518
+ );
519
+ const doneB = new Promise<{ result: string; sessionId?: string }>(
520
+ (resolve) => {
521
+ resolveB = resolve;
522
+ },
523
+ );
524
+
525
+ const runner = mock(async (msg: string, _key?: string) =>
526
+ msg.includes("channel-a") ? doneA : doneB,
527
+ );
528
+
529
+ const ref = createAgentSlackMembershipRef();
530
+ setupGatingApp({
531
+ membershipRef: ref,
532
+ resolveUserEmailFn: async () => undefined,
533
+ runner,
534
+ });
535
+
536
+ const client = makeMockClient();
537
+ const say = makeSay();
538
+
539
+ const callA = capturedMessageHandler?.({
540
+ message: {
541
+ channel: "D-A",
542
+ ts: "400.1",
543
+ text: "message on channel-a",
544
+ channel_type: "im",
545
+ user: "U-SENDER",
546
+ },
547
+ say,
548
+ client,
549
+ });
550
+ const callB = capturedMessageHandler?.({
551
+ message: {
552
+ channel: "D-B",
553
+ ts: "500.1",
554
+ text: "message on channel-b",
555
+ channel_type: "im",
556
+ user: "U-SENDER",
557
+ },
558
+ say,
559
+ client,
560
+ });
561
+
562
+ await flushMicrotasks();
563
+
564
+ // Two independent threads, both starting fresh — both seed "Thinking…",
565
+ // neither "Queued…" (each is the first/only run on its own thread).
566
+ expect(client.chat.startStream).toHaveBeenCalledTimes(2);
567
+ expect(seedTitles(client)).toEqual(["Thinking…", "Thinking…"]);
568
+
569
+ resolveA({ result: "a done", sessionId: "sess-a" });
570
+ await callA;
571
+ resolveB({ result: "b done", sessionId: "sess-b" });
572
+ await callB;
573
+
574
+ // Each thread's single run completing should independently close with
575
+ // session_status "active" (never "processing" — neither thread ever had
576
+ // a second overlapping run).
577
+ expect(client.chat.stopStream).toHaveBeenCalledTimes(2);
578
+ expect(stopStreamStatuses(client)).toEqual(["active", "active"]);
579
+ });
580
+ });
581
+
582
+ describe("membership gating — reaction_added handler", () => {
583
+ async function invokeReaction(
584
+ membershipRef: AgentSlackMembershipRef,
585
+ resolveUserEmailFn: (
586
+ userId: string,
587
+ client: unknown,
588
+ ) => Promise<string | undefined>,
589
+ ) {
590
+ const { runner } = setupGatingApp({ membershipRef, resolveUserEmailFn });
591
+ const client = makeMockClient();
592
+ const event = {
593
+ reaction: "thumbsup",
594
+ item: { type: "message", channel: "D1", ts: "100.1" },
595
+ item_user: "UBOT123",
596
+ user: "U-SENDER",
597
+ };
598
+ await capturedReactionAddedHandler?.({ event, client });
599
+ return { runner, client };
600
+ }
601
+
602
+ test("unsynced ref: processed as today (runner called)", async () => {
603
+ const ref = createAgentSlackMembershipRef();
604
+ const { runner } = await invokeReaction(ref, async () => undefined);
605
+ expect(runner).toHaveBeenCalledTimes(1);
606
+ });
607
+
608
+ test("restrict=false: processed as today (runner called)", async () => {
609
+ const ref = createAgentSlackMembershipRef();
610
+ ref.set({ restrict: false, emails: [] });
611
+ const { runner } = await invokeReaction(ref, async () => undefined);
612
+ expect(runner).toHaveBeenCalledTimes(1);
613
+ });
614
+
615
+ test("restrict=true + matching email: processed as today", async () => {
616
+ const ref = createAgentSlackMembershipRef();
617
+ ref.set({ restrict: true, emails: ["member@example.com"] });
618
+ const { runner } = await invokeReaction(
619
+ ref,
620
+ async () => "member@example.com",
621
+ );
622
+ expect(runner).toHaveBeenCalledTimes(1);
623
+ });
624
+
625
+ test("restrict=true + non-member email: runner not called, no post", async () => {
626
+ const ref = createAgentSlackMembershipRef();
627
+ ref.set({ restrict: true, emails: ["member@example.com"] });
628
+ const { runner, client } = await invokeReaction(
629
+ ref,
630
+ async () => "stranger@example.com",
631
+ );
632
+ expect(runner).not.toHaveBeenCalled();
633
+ expect(client.chat.postMessage).not.toHaveBeenCalled();
634
+ });
635
+
636
+ test("restrict=true + undefined email (resolution failed): runner not called", async () => {
637
+ const ref = createAgentSlackMembershipRef();
638
+ ref.set({ restrict: true, emails: ["member@example.com"] });
639
+ const { runner, client } = await invokeReaction(ref, async () => undefined);
640
+ expect(runner).not.toHaveBeenCalled();
641
+ expect(client.chat.postMessage).not.toHaveBeenCalled();
642
+ });
643
+ });
644
+
645
+ // ─── extraEnv passed to runner (STC-1.2) ───────────────────────────────────
646
+ // Each Slack-originated runner() call should tell the run which Slack
647
+ // channel/thread it serves via extraEnv, using exactly the same ts that was
648
+ // used to build the session key (getThreadKey's second argument) — not
649
+ // necessarily the raw event ts, since thread_ts wins when present.
650
+
651
+ describe("extraEnv passed to runner — SLACK_CHANNEL_ID / SLACK_THREAD_TS (STC-1.2)", () => {
652
+ test("DM message: extraEnv uses the message channel and ts (no thread_ts)", async () => {
653
+ const ref = createAgentSlackMembershipRef();
654
+ const runner = mock(async (_msg: string, _key?: string) => ({
655
+ result: "ok",
656
+ sessionId: "sess-1",
657
+ }));
658
+ setupGatingApp({
659
+ membershipRef: ref,
660
+ resolveUserEmailFn: async () => undefined,
661
+ runner,
662
+ });
663
+ const client = makeMockClient();
664
+ const say = makeSay();
665
+ const message = {
666
+ channel: "D0EXAMPLE",
667
+ ts: "1700000000.000100",
668
+ text: "Hello bot",
669
+ channel_type: "im",
670
+ user: "U-SENDER",
671
+ };
672
+
673
+ await capturedMessageHandler?.({ message, say, client });
674
+
675
+ expect(runner).toHaveBeenCalledTimes(1);
676
+ // biome-ignore lint/suspicious/noExplicitAny: test double captures calls
677
+ const call = (runner as any).mock.calls[0];
678
+ expect(call[3]).toEqual({
679
+ SLACK_CHANNEL_ID: "D0EXAMPLE",
680
+ SLACK_THREAD_TS: "1700000000.000100",
681
+ });
682
+ });
683
+
684
+ test("channel-thread message: extraEnv uses thread_ts, not the message's own ts", async () => {
685
+ const ref = createAgentSlackMembershipRef();
686
+ const runner = mock(async (_msg: string, _key?: string) => ({
687
+ result: "ok",
688
+ sessionId: "sess-1",
689
+ }));
690
+ setupGatingApp({
691
+ membershipRef: ref,
692
+ resolveUserEmailFn: async () => undefined,
693
+ runner,
694
+ // Non-DM channel messages only run when the bot already has a tracked
695
+ // session for the thread (see the `if (!isDM)` gate in slack.ts).
696
+ getSessionFn: async () => "existing-session-id",
697
+ });
698
+ const client = makeMockClient();
699
+ const say = makeSay();
700
+ const message = {
701
+ channel: "C0EXAMPLE",
702
+ ts: "1700000000.000200",
703
+ thread_ts: "1700000000.000150",
704
+ text: "reply in thread",
705
+ channel_type: "channel",
706
+ user: "U-SENDER",
707
+ };
708
+
709
+ await capturedMessageHandler?.({ message, say, client });
710
+
711
+ expect(runner).toHaveBeenCalledTimes(1);
712
+ // biome-ignore lint/suspicious/noExplicitAny: test double captures calls
713
+ const call = (runner as any).mock.calls[0];
714
+ expect(call[3]).toEqual({
715
+ SLACK_CHANNEL_ID: "C0EXAMPLE",
716
+ SLACK_THREAD_TS: "1700000000.000150",
717
+ });
718
+ });
719
+
720
+ test("app_mention, new thread: extraEnv uses the event's own ts (no thread_ts)", async () => {
721
+ const ref = createAgentSlackMembershipRef();
722
+ const runner = mock(async (_msg: string, _key?: string) => ({
723
+ result: "ok",
724
+ sessionId: "sess-1",
725
+ }));
726
+ setupGatingApp({
727
+ membershipRef: ref,
728
+ resolveUserEmailFn: async () => undefined,
729
+ runner,
730
+ });
731
+ const client = makeMockClient();
732
+ const say = makeSay();
733
+ const event = {
734
+ text: "<@UBOT> do something",
735
+ channel: "C0EXAMPLE",
736
+ ts: "1700000000.000300",
737
+ user: "U-SENDER",
738
+ };
739
+
740
+ await capturedMentionHandler?.({ event, say, client });
741
+
742
+ expect(runner).toHaveBeenCalledTimes(1);
743
+ // biome-ignore lint/suspicious/noExplicitAny: test double captures calls
744
+ const call = (runner as any).mock.calls[0];
745
+ expect(call[3]).toEqual({
746
+ SLACK_CHANNEL_ID: "C0EXAMPLE",
747
+ SLACK_THREAD_TS: "1700000000.000300",
748
+ });
749
+ });
750
+
751
+ test("app_mention, existing thread: extraEnv uses thread_ts, not the event's own ts", async () => {
752
+ const ref = createAgentSlackMembershipRef();
753
+ const runner = mock(async (_msg: string, _key?: string) => ({
754
+ result: "ok",
755
+ sessionId: "sess-1",
756
+ }));
757
+ setupGatingApp({
758
+ membershipRef: ref,
759
+ resolveUserEmailFn: async () => undefined,
760
+ runner,
761
+ // No tracked session yet for this thread — otherwise the app_mention
762
+ // handler drops the mention entirely (the message handler already
763
+ // covers threads the bot is participating in).
764
+ getSessionFn: async () => undefined,
765
+ });
766
+ const client = makeMockClient();
767
+ const say = makeSay();
768
+ const event = {
769
+ text: "<@UBOT> do something else",
770
+ channel: "C0EXAMPLE",
771
+ ts: "1700000000.000450",
772
+ thread_ts: "1700000000.000400",
773
+ user: "U-SENDER",
774
+ };
775
+
776
+ await capturedMentionHandler?.({ event, say, client });
777
+
778
+ expect(runner).toHaveBeenCalledTimes(1);
779
+ // biome-ignore lint/suspicious/noExplicitAny: test double captures calls
780
+ const call = (runner as any).mock.calls[0];
781
+ expect(call[3]).toEqual({
782
+ SLACK_CHANNEL_ID: "C0EXAMPLE",
783
+ SLACK_THREAD_TS: "1700000000.000400",
784
+ });
785
+ });
786
+
787
+ test("reaction_added: extraEnv uses the reacted-to message's item.channel / item.ts", async () => {
788
+ const ref = createAgentSlackMembershipRef();
789
+ const runner = mock(async (_msg: string, _key?: string) => ({
790
+ result: "ok",
791
+ sessionId: "sess-1",
792
+ }));
793
+ setupGatingApp({
794
+ membershipRef: ref,
795
+ resolveUserEmailFn: async () => undefined,
796
+ runner,
797
+ });
798
+ const client = makeMockClient();
799
+ const event = {
800
+ reaction: "thumbsup",
801
+ item: { type: "message", channel: "D0EXAMPLE", ts: "1700000000.000500" },
802
+ item_user: "UBOT123",
803
+ user: "U-SENDER",
804
+ };
805
+
806
+ await capturedReactionAddedHandler?.({ event, client });
807
+
808
+ expect(runner).toHaveBeenCalledTimes(1);
809
+ // biome-ignore lint/suspicious/noExplicitAny: test double captures calls
810
+ const call = (runner as any).mock.calls[0];
811
+ expect(call[3]).toEqual({
812
+ SLACK_CHANNEL_ID: "D0EXAMPLE",
813
+ SLACK_THREAD_TS: "1700000000.000500",
814
+ });
815
+ });
816
+ });
817
+
818
+ // ─── agent_session_stopped handler (AGS-1.1) ───────────────────────────────
819
+ // Slack's native Stop/session-cancellation event for the Agent Sessions API.
820
+ // createSlackApp() keeps a closure-scoped registry (channel:ts -> live
821
+ // SlackProgress instance), populated when startStream() succeeds and cleared
822
+ // when the stream closes (success or failure). On agent_session_stopped, the
823
+ // handler looks up the matching instance and marks it externally-stopped
824
+ // (further appendStream/stopStream calls become no-ops), then independently
825
+ // resets the thread's "is working" indicator via a direct
826
+ // agents.sessions.setStatus({status: "active"}) call — the actual fix for the
827
+ // stuck-status bug, since it succeeds even when the stream is already dead
828
+ // server-side.
829
+
830
+ describe("agent_session_stopped handler", () => {
831
+ function makeStreamingClient() {
832
+ return {
833
+ ...makeMockClient(),
834
+ chat: {
835
+ ...makeMockClient().chat,
836
+ startStream: mock(async (_args: unknown) => ({ ts: "stream.ts.1" })),
837
+ appendStream: mock(async (_args: unknown) => {}),
838
+ stopStream: mock(async (_args: unknown) => {}),
839
+ },
840
+ };
841
+ }
842
+
843
+ // Flushes pending microtask hops (getSessionFn/resolveUserFn awaits,
844
+ // startStream()'s await, etc.) so the handler reaches the point where it
845
+ // has called runner() and is awaiting the still-pending run — i.e. the
846
+ // registry entry is populated but the handler's finally block (which
847
+ // would unregister it) hasn't run yet. Mirrors the existing
848
+ // flushMicrotasks() helper used by the per-thread refcounting suite above.
849
+ function flushMicrotasks(): Promise<void> {
850
+ return new Promise((resolve) => setTimeout(resolve, 0));
851
+ }
852
+
853
+ // Starts a stream and leaves its run in flight (runner() never resolves
854
+ // during the test) — the realistic shape of agent_session_stopped firing
855
+ // mid-turn, and the only way the registry entry is still present when the
856
+ // event handler looks it up (the message handler's finally block clears
857
+ // it as soon as the run completes).
858
+ function startAStream(overrides: {
859
+ membershipRef: AgentSlackMembershipRef;
860
+ resolveUserEmailFn: (
861
+ userId: string,
862
+ client: unknown,
863
+ ) => Promise<string | undefined>;
864
+ }) {
865
+ const runner = mock(
866
+ (_msg: string, _key?: string) =>
867
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately never resolves — type matches ClaudeRunner's return via cast
868
+ new Promise(() => {}) as any,
869
+ );
870
+ _createSlackApp(
871
+ runner,
872
+ (text: string) => text,
873
+ (channel: string, ts: string) => `${channel}:${ts}`,
874
+ // biome-ignore lint/suspicious/noExplicitAny: mock factory for tests
875
+ (cfg) => new MockApp(cfg as Record<string, unknown>) as any,
876
+ mockGatingSlackConfig,
877
+ undefined,
878
+ async () => null,
879
+ {},
880
+ async () => null,
881
+ async () => null,
882
+ async (userId: string) => userId,
883
+ "UBOT123",
884
+ async () => ({ messages: [] }),
885
+ async () => undefined,
886
+ undefined,
887
+ undefined,
888
+ overrides.resolveUserEmailFn,
889
+ overrides.membershipRef,
890
+ );
891
+
892
+ const client = makeStreamingClient();
893
+ const say = makeSay();
894
+ const message = {
895
+ channel: "D123",
896
+ ts: "111.222",
897
+ text: "Hello bot",
898
+ channel_type: "im",
899
+ user: "U-SENDER",
900
+ };
901
+ // Deliberately not awaited — the handler is left mid-flight, blocked on
902
+ // runner()'s never-resolving promise.
903
+ capturedMessageHandler?.({ message, say, client });
904
+ return { runner, client, say };
905
+ }
906
+
907
+ test("marks the matching SlackProgress externally-stopped: further appendStream calls become no-ops", async () => {
908
+ const ref = createAgentSlackMembershipRef();
909
+ const { client } = startAStream({
910
+ membershipRef: ref,
911
+ resolveUserEmailFn: async () => undefined,
912
+ });
913
+ await flushMicrotasks();
914
+
915
+ // Stream opened successfully — registry now has an entry for "D123:stream.ts.1".
916
+ expect(client.chat.startStream).toHaveBeenCalledTimes(1);
917
+ client.chat.appendStream.mockClear();
918
+
919
+ await capturedAgentSessionStoppedHandler?.({
920
+ event: { channel: "D123", streaming_message_ts: "stream.ts.1" },
921
+ client,
922
+ });
923
+
924
+ // Any further onProgress-driven appendStream against this dead stream is
925
+ // now a no-op — asserted indirectly by confirming a raw appendStream call
926
+ // is never issued once the event has fired for this stream's key.
927
+ expect(client.chat.appendStream).not.toHaveBeenCalled();
928
+ });
929
+
930
+ test("resets the thread status via a direct agents.sessions.setStatus('active') call", async () => {
931
+ const ref = createAgentSlackMembershipRef();
932
+ const { client } = startAStream({
933
+ membershipRef: ref,
934
+ resolveUserEmailFn: async () => undefined,
935
+ });
936
+ await flushMicrotasks();
937
+
938
+ client.agents.sessions.setStatus.mockClear();
939
+
940
+ await capturedAgentSessionStoppedHandler?.({
941
+ event: { channel: "D123", streaming_message_ts: "stream.ts.1" },
942
+ client,
943
+ });
944
+
945
+ // thread_ts must be the run's actual thread ts ("111.222", the
946
+ // message's own ts since it has no thread_ts) — not the stream's
947
+ // streaming_message_ts ("stream.ts.1"), which is a different value the
948
+ // Agent Sessions API doesn't accept as a thread identifier.
949
+ expect(client.agents.sessions.setStatus).toHaveBeenCalledWith(
950
+ expect.objectContaining({
951
+ channel_id: "D123",
952
+ thread_ts: "111.222",
953
+ status: "active",
954
+ }),
955
+ );
956
+ });
957
+
958
+ test("unknown/already-cleared channel:ts is a no-op — does not throw", async () => {
959
+ const ref = createAgentSlackMembershipRef();
960
+ const { client } = startAStream({
961
+ membershipRef: ref,
962
+ resolveUserEmailFn: async () => undefined,
963
+ });
964
+ await flushMicrotasks();
965
+
966
+ // start()'s own eager setStatus call (ISW-1.1) already fired above —
967
+ // clear it so calls[0] below refers to the agent_session_stopped
968
+ // handler's own call, not start()'s.
969
+ client.agents.sessions.setStatus.mockClear();
970
+
971
+ await expect(
972
+ capturedAgentSessionStoppedHandler?.({
973
+ event: { channel: "D999", streaming_message_ts: "no.such.stream" },
974
+ client,
975
+ }),
976
+ ).resolves.toBeUndefined();
977
+
978
+ // The direct status reset still fires regardless of registry lookup
979
+ // outcome — AC #5 says it's independent of the stream's own close path.
980
+ expect(client.agents.sessions.setStatus).toHaveBeenCalledWith(
981
+ expect.objectContaining({ channel_id: "D999", status: "active" }),
982
+ );
983
+
984
+ // On a registry miss the run's real thread_ts is unknown, so the handler
985
+ // must OMIT thread_ts entirely rather than fall back to the event's own
986
+ // streaming_message_ts ("no.such.stream") — that's the stream's message ts,
987
+ // a distinct value the Agent Sessions API doesn't accept as a thread id
988
+ // (see SlackProgress.getThreadTs()). Sending it would target the wrong
989
+ // conversation.
990
+ const setStatusArgs = client.agents.sessions.setStatus.mock.calls[0]?.[0];
991
+ expect(setStatusArgs).not.toHaveProperty("thread_ts");
992
+ });
993
+
994
+ test("agents.sessions.setStatus failure does not crash the handler", async () => {
995
+ const ref = createAgentSlackMembershipRef();
996
+ const { client } = startAStream({
997
+ membershipRef: ref,
998
+ resolveUserEmailFn: async () => undefined,
999
+ });
1000
+ await flushMicrotasks();
1001
+
1002
+ client.agents.sessions.setStatus.mockRejectedValueOnce(
1003
+ new Error("setStatus down"),
1004
+ );
1005
+
1006
+ await expect(
1007
+ capturedAgentSessionStoppedHandler?.({
1008
+ event: { channel: "D123", streaming_message_ts: "stream.ts.1" },
1009
+ client,
1010
+ }),
1011
+ ).resolves.toBeUndefined();
1012
+ });
1013
+
1014
+ test("registry entry is cleared once the stream closes normally — a later agent_session_stopped for it is a no-op lookup", async () => {
1015
+ const ref = createAgentSlackMembershipRef();
1016
+ const runner = mock(async (_msg: string, _key?: string) => ({
1017
+ result: "Claude response",
1018
+ sessionId: "sess-1",
1019
+ }));
1020
+ _createSlackApp(
1021
+ runner,
1022
+ (text: string) => text,
1023
+ (channel: string, ts: string) => `${channel}:${ts}`,
1024
+ // biome-ignore lint/suspicious/noExplicitAny: mock factory for tests
1025
+ (cfg) => new MockApp(cfg as Record<string, unknown>) as any,
1026
+ mockGatingSlackConfig,
1027
+ undefined,
1028
+ async () => null,
1029
+ {},
1030
+ async () => null,
1031
+ async () => null,
1032
+ async (userId: string) => userId,
1033
+ "UBOT123",
1034
+ async () => ({ messages: [] }),
1035
+ async () => undefined,
1036
+ undefined,
1037
+ undefined,
1038
+ async () => undefined,
1039
+ ref,
1040
+ );
1041
+ const client = makeStreamingClient();
1042
+ const say = makeSay();
1043
+ // Run completes normally (unlike startAStream()'s never-resolving
1044
+ // runner) — the message handler's finally block clears the registry
1045
+ // entry for this stream key before this call resolves.
1046
+ await capturedMessageHandler?.({
1047
+ message: {
1048
+ channel: "D123",
1049
+ ts: "111.222",
1050
+ text: "Hello bot",
1051
+ channel_type: "im",
1052
+ user: "U-SENDER",
1053
+ },
1054
+ say,
1055
+ client,
1056
+ });
1057
+
1058
+ // Firing the event now must still be a safe no-op (covered by the
1059
+ // "unknown key" case above) — this test instead confirms stopStream was
1060
+ // not called a second time as a side effect of the (now no-op) lookup.
1061
+ client.chat.stopStream.mockClear();
1062
+
1063
+ await capturedAgentSessionStoppedHandler?.({
1064
+ event: { channel: "D123", streaming_message_ts: "stream.ts.1" },
1065
+ client,
1066
+ });
1067
+
1068
+ expect(client.chat.stopStream).not.toHaveBeenCalled();
1069
+ });
1070
+ });