@app-vitals/agent-runtime 0.0.1 → 0.1.1

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 +50 -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,806 @@
1
+ /**
2
+ * agent/src/runtime/slack-progress.unit.test.ts
3
+ *
4
+ * Unit tests for SlackProgress — the live Thinking Steps stream driver for
5
+ * Slack thread replies (STS2-1.1, fixed for STS-1.1/PR #3034's broken first
6
+ * cut, whose `{type: "task_update", text}` chunk shape was rejected by
7
+ * Slack's real API on every call — see the module doc comment in
8
+ * slack-progress.ts for the full root-cause writeup; TSD-1.1 then removed
9
+ * the feature flag that gated it, making streaming the only mode; ISW-1.1
10
+ * then made agents.sessions.setStatus an eager, unconditional call rather
11
+ * than a stream-failure-only fallback):
12
+ * - start() calls agents.sessions.setStatus({status: "processing"}) before
13
+ * startStream() runs, so Slack's native "is working" indicator shows
14
+ * immediately in channels too (where chat.startStream's own status
15
+ * effect is gated behind opening the message's thread).
16
+ * - start() seeds the stream with an immediate task_update card (no blank
17
+ * gap before the first real phase transition).
18
+ * - every task_update chunk uses {type, id, title, status}, reusing one
19
+ * stable id across every update in the run (never the old {type, text}
20
+ * shape, and never a fresh id per update).
21
+ * - deliverContent() sends the final answer as its own markdown_text
22
+ * chunk plus a status:"complete" task_update, and returns undefined
23
+ * whenever delivery fails or streaming is unavailable so the caller
24
+ * falls back to say()/blocksConverter.
25
+ * - finish() closes any still-in_progress card with status:"complete",
26
+ * always calls agents.sessions.setStatus with the final session status,
27
+ * and independently also passes that status through to stopStream
28
+ * whenever a stream did open.
29
+ *
30
+ * The mock Slack client is a plain object of mock fns — no mock.module(), no
31
+ * global overrides. No Clock injection is needed: SlackProgress does no
32
+ * time-based scheduling.
33
+ */
34
+
35
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
36
+ import { PROGRESS_LABELS } from "@app-vitals/agent-lib/progress-phases";
37
+ import { SlackProgress } from "./slack-progress.ts";
38
+
39
+ // ─── Mock Slack client ────────────────────────────────────────────────────────
40
+
41
+ function makeMockClient() {
42
+ return {
43
+ agents: {
44
+ sessions: {
45
+ setStatus: mock(async (_args: unknown) => {}),
46
+ },
47
+ },
48
+ chat: {
49
+ startStream: mock(async (_args: unknown) => ({ ts: "stream.ts.1" })),
50
+ appendStream: mock(async (_args: unknown) => {}),
51
+ stopStream: mock(async (_args: unknown) => {}),
52
+ },
53
+ };
54
+ }
55
+
56
+ const CHANNEL = "D123";
57
+ const THREAD_TS = "111.222";
58
+
59
+ function makeProgress(
60
+ overrides: {
61
+ client?: ReturnType<typeof makeMockClient>;
62
+ isDM?: boolean;
63
+ recipientUserId?: string;
64
+ recipientTeamId?: string;
65
+ } = {},
66
+ ) {
67
+ const client = overrides.client ?? makeMockClient();
68
+ const progress = new SlackProgress({
69
+ client,
70
+ channel: CHANNEL,
71
+ threadTs: THREAD_TS,
72
+ // CHANNEL ("D123") is a DM-shaped id — default to isDM: true so the
73
+ // existing DM-focused tests below keep exercising DM behavior (no
74
+ // recipient_team_id/recipient_user_id) unless a test explicitly opts
75
+ // into a channel/group scenario via the override (STS-2.1).
76
+ isDM: overrides.isDM ?? true,
77
+ recipientUserId: overrides.recipientUserId,
78
+ recipientTeamId: overrides.recipientTeamId,
79
+ });
80
+ return { progress, client };
81
+ }
82
+
83
+ /** Pulls the single task_update chunk out of an appendStream/startStream call. */
84
+ function taskUpdateChunk(call: { chunks: Array<Record<string, unknown>> }) {
85
+ return call.chunks.find((c) => c.type === "task_update");
86
+ }
87
+
88
+ // ─── Thinking Steps stream ──────────────────────────────────────────────────
89
+
90
+ describe("SlackProgress — Thinking Steps stream", () => {
91
+ test("start() opens a chat.startStream with task_display_mode timeline", async () => {
92
+ const { progress, client } = makeProgress();
93
+ await progress.start();
94
+ expect(client.chat.startStream).toHaveBeenCalledWith({
95
+ channel: CHANNEL,
96
+ thread_ts: THREAD_TS,
97
+ task_display_mode: "timeline",
98
+ });
99
+ });
100
+
101
+ // ─── recipient_team_id / recipient_user_id (STS-2.1) ───────────────────
102
+ // Slack's own SDK types document both fields as "required when starting a
103
+ // streaming conversation outside of a DM" — omitting them in a channel/
104
+ // group thread makes every chat.startStream call fail with
105
+ // missing_recipient_team_id, which is the root cause this task fixes.
106
+
107
+ test("start() includes recipient_team_id and recipient_user_id for a non-DM (channel/group) thread (AC #1)", async () => {
108
+ const { progress, client } = makeProgress({
109
+ isDM: false,
110
+ recipientUserId: "U-SENDER",
111
+ recipientTeamId: "T-TEAM",
112
+ });
113
+ await progress.start();
114
+ expect(client.chat.startStream).toHaveBeenCalledWith({
115
+ channel: CHANNEL,
116
+ thread_ts: THREAD_TS,
117
+ task_display_mode: "timeline",
118
+ recipient_team_id: "T-TEAM",
119
+ recipient_user_id: "U-SENDER",
120
+ });
121
+ });
122
+
123
+ test("start() omits recipient_team_id and recipient_user_id for a DM thread, even when supplied (AC #2)", async () => {
124
+ const { progress, client } = makeProgress({
125
+ isDM: true,
126
+ recipientUserId: "U-SENDER",
127
+ recipientTeamId: "T-TEAM",
128
+ });
129
+ await progress.start();
130
+ const call = client.chat.startStream.mock.calls[0]?.[0] as Record<
131
+ string,
132
+ unknown
133
+ >;
134
+ expect(call).not.toHaveProperty("recipient_team_id");
135
+ expect(call).not.toHaveProperty("recipient_user_id");
136
+ });
137
+
138
+ // ISW-1.1: agents.sessions.setStatus is called eagerly, before
139
+ // startStream() even runs — channel @mentions get no visible "is working"
140
+ // signal until chat.startStream's timeline card becomes visible (which
141
+ // requires opening the message's thread in a channel), but setStatus needs
142
+ // only channel_id/thread_ts and shows immediately for both DMs and
143
+ // channels.
144
+ test("start() calls setStatus with status:processing before startStream() runs, for a DM thread (AC #1)", async () => {
145
+ const callOrder: string[] = [];
146
+ const client = makeMockClient();
147
+ client.agents.sessions.setStatus.mockImplementationOnce(async () => {
148
+ callOrder.push("setStatus");
149
+ });
150
+ client.chat.startStream.mockImplementationOnce(async () => {
151
+ callOrder.push("startStream");
152
+ return { ts: "stream.ts.1" };
153
+ });
154
+ const { progress } = makeProgress({ client, isDM: true });
155
+ await progress.start();
156
+ expect(client.agents.sessions.setStatus).toHaveBeenCalledWith({
157
+ channel_id: CHANNEL,
158
+ thread_ts: THREAD_TS,
159
+ status: "processing",
160
+ });
161
+ expect(callOrder).toEqual(["setStatus", "startStream"]);
162
+ });
163
+
164
+ test("start() calls setStatus with status:processing before startStream() runs, for a non-DM (channel) thread (AC #1)", async () => {
165
+ const callOrder: string[] = [];
166
+ const client = makeMockClient();
167
+ client.agents.sessions.setStatus.mockImplementationOnce(async () => {
168
+ callOrder.push("setStatus");
169
+ });
170
+ client.chat.startStream.mockImplementationOnce(async () => {
171
+ callOrder.push("startStream");
172
+ return { ts: "stream.ts.1" };
173
+ });
174
+ const { progress } = makeProgress({
175
+ client,
176
+ isDM: false,
177
+ recipientUserId: "U-SENDER",
178
+ recipientTeamId: "T-TEAM",
179
+ });
180
+ await progress.start();
181
+ expect(client.agents.sessions.setStatus).toHaveBeenCalledWith({
182
+ channel_id: CHANNEL,
183
+ thread_ts: THREAD_TS,
184
+ status: "processing",
185
+ });
186
+ expect(callOrder).toEqual(["setStatus", "startStream"]);
187
+ });
188
+
189
+ // AC #4: the stream is seeded with an immediate card on open — no blank
190
+ // gap before the first real phase transition.
191
+ test("start() seeds the stream with an immediate in_progress task_update card (AC #4)", async () => {
192
+ const { progress, client } = makeProgress();
193
+ await progress.start();
194
+ expect(client.chat.appendStream).toHaveBeenCalledTimes(1);
195
+ const call = client.chat.appendStream.mock.calls[0]?.[0] as {
196
+ chunks: Array<Record<string, unknown>>;
197
+ };
198
+ const chunk = taskUpdateChunk(call);
199
+ expect(chunk).toMatchObject({ type: "task_update", status: "in_progress" });
200
+ expect(chunk?.id).toBeDefined();
201
+ expect(typeof chunk?.title).toBe("string");
202
+ });
203
+
204
+ // AC #5: seed text reflects whether another message is already in flight
205
+ // ("Queued…" when the caller signals a burst) vs "Thinking…" otherwise.
206
+ test("start() seeds with 'Thinking…' by default", async () => {
207
+ const { progress, client } = makeProgress();
208
+ await progress.start();
209
+ const call = client.chat.appendStream.mock.calls[0]?.[0] as {
210
+ chunks: Array<Record<string, unknown>>;
211
+ };
212
+ expect(taskUpdateChunk(call)?.title).toBe("Thinking…");
213
+ });
214
+
215
+ test("start() seeds with 'Queued…' when told another message is already in flight", async () => {
216
+ const { progress, client } = makeProgress();
217
+ await progress.start({ queued: true });
218
+ const call = client.chat.appendStream.mock.calls[0]?.[0] as {
219
+ chunks: Array<Record<string, unknown>>;
220
+ };
221
+ expect(taskUpdateChunk(call)?.title).toBe("Queued…");
222
+ });
223
+
224
+ // AC #1: task_update chunks use type/id/title/status, with a single stable
225
+ // id reused across every update in the run — not the old {type, text}
226
+ // shape, and never a fresh id per update (which stacks a separate card).
227
+ test("onProgress appends a task_update chunk with the new type/id/title/status shape", async () => {
228
+ const { progress, client } = makeProgress();
229
+ await progress.start();
230
+ client.chat.appendStream.mockClear();
231
+ progress.onProgress({}, "reading");
232
+ expect(client.chat.appendStream).toHaveBeenCalledTimes(1);
233
+ const call = client.chat.appendStream.mock.calls[0]?.[0] as {
234
+ chunks: Array<Record<string, unknown>>;
235
+ };
236
+ const chunk = taskUpdateChunk(call);
237
+ expect(chunk).toMatchObject({
238
+ type: "task_update",
239
+ title: PROGRESS_LABELS.reading,
240
+ status: "in_progress",
241
+ });
242
+ expect(chunk?.id).toBeDefined();
243
+ // Explicitly not the old rejected shape.
244
+ expect(chunk).not.toHaveProperty("text");
245
+ });
246
+
247
+ test("onProgress reuses the same stable id as the seed card across every update", async () => {
248
+ const { progress, client } = makeProgress();
249
+ await progress.start();
250
+ const seedCall = client.chat.appendStream.mock.calls[0]?.[0] as {
251
+ chunks: Array<Record<string, unknown>>;
252
+ };
253
+ const seedId = taskUpdateChunk(seedCall)?.id;
254
+
255
+ progress.onProgress({}, "reading");
256
+ progress.onProgress({}, "editing");
257
+
258
+ const calls = client.chat.appendStream.mock.calls as unknown as Array<
259
+ [{ chunks: Array<Record<string, unknown>> }]
260
+ >;
261
+ for (const [call] of calls) {
262
+ expect(taskUpdateChunk(call)?.id).toBe(seedId as string);
263
+ }
264
+ });
265
+
266
+ test("onProgress collapses repeated identical phases into a single appendStream call", async () => {
267
+ const { progress, client } = makeProgress();
268
+ await progress.start();
269
+ client.chat.appendStream.mockClear();
270
+ progress.onProgress({}, "reading");
271
+ progress.onProgress({}, "reading");
272
+ progress.onProgress({}, "reading");
273
+ expect(client.chat.appendStream).toHaveBeenCalledTimes(1);
274
+ });
275
+
276
+ test("onProgress fires again once the phase transitions to a new distinct value", async () => {
277
+ const { progress, client } = makeProgress();
278
+ await progress.start();
279
+ client.chat.appendStream.mockClear();
280
+ progress.onProgress({}, "reading");
281
+ progress.onProgress({}, "reading");
282
+ progress.onProgress({}, "editing");
283
+ expect(client.chat.appendStream).toHaveBeenCalledTimes(2);
284
+ const call = client.chat.appendStream.mock.calls[1]?.[0] as {
285
+ chunks: Array<Record<string, unknown>>;
286
+ };
287
+ expect(taskUpdateChunk(call)?.title).toBe(PROGRESS_LABELS.editing);
288
+ });
289
+
290
+ test("onProgress with an undefined phase does not append a chunk", async () => {
291
+ const { progress, client } = makeProgress();
292
+ await progress.start();
293
+ client.chat.appendStream.mockClear();
294
+ progress.onProgress({}, undefined);
295
+ expect(client.chat.appendStream).not.toHaveBeenCalled();
296
+ });
297
+
298
+ test("onProgress does not throw synchronously (fire-and-forget)", async () => {
299
+ const { progress } = makeProgress();
300
+ await progress.start();
301
+ expect(() => progress.onProgress({}, "reading")).not.toThrow();
302
+ });
303
+
304
+ // AC #2: the final answer is delivered as its own markdown_text chunk via
305
+ // chat.appendStream, alongside a status:"complete" task_update — NOT via
306
+ // chat.stopStream's top-level markdown_text param.
307
+ test("deliverContent() sends the text as a markdown_text chunk via chat.appendStream", async () => {
308
+ const { progress, client } = makeProgress();
309
+ await progress.start();
310
+ client.chat.appendStream.mockClear();
311
+ const delivered = await progress.deliverContent("Here is the answer.");
312
+ expect(delivered).toBe(true);
313
+ expect(client.chat.appendStream).toHaveBeenCalledTimes(1);
314
+ const call = client.chat.appendStream.mock.calls[0]?.[0] as {
315
+ chunks: Array<Record<string, unknown>>;
316
+ };
317
+ expect(call.chunks).toContainEqual({
318
+ type: "markdown_text",
319
+ text: "Here is the answer.",
320
+ });
321
+ });
322
+
323
+ test("deliverContent() also sends a status:complete task_update alongside the markdown_text chunk (AC #2)", async () => {
324
+ const { progress, client } = makeProgress();
325
+ await progress.start();
326
+ client.chat.appendStream.mockClear();
327
+ await progress.deliverContent("Here is the answer.");
328
+ const call = client.chat.appendStream.mock.calls[0]?.[0] as {
329
+ chunks: Array<Record<string, unknown>>;
330
+ };
331
+ const chunk = taskUpdateChunk(call);
332
+ expect(chunk?.status).toBe("complete");
333
+ // Completion text never ends in an ellipsis, and is empty rather than
334
+ // "Done" (STS-2.1 AC #4).
335
+ const title = chunk?.title as string;
336
+ expect(title.endsWith("…")).toBe(false);
337
+ expect(chunk?.title).toBe("");
338
+ });
339
+
340
+ test("deliverContent() does not call chat.stopStream with a top-level markdown_text param", async () => {
341
+ const { progress, client } = makeProgress();
342
+ await progress.start();
343
+ await progress.deliverContent("Here is the answer.");
344
+ await progress.finish();
345
+ for (const [args] of client.chat.stopStream.mock.calls) {
346
+ expect(args).not.toHaveProperty("markdown_text");
347
+ }
348
+ });
349
+
350
+ test("deliverContent() returns undefined when no stream is open (startStream never returned a ts)", async () => {
351
+ const client = makeMockClient();
352
+ client.chat.startStream.mockResolvedValueOnce(undefined as never);
353
+ const { progress } = makeProgress({ client });
354
+ await progress.start();
355
+ const delivered = await progress.deliverContent("Here is the answer.");
356
+ expect(delivered).toBeUndefined();
357
+ });
358
+
359
+ test("deliverContent() returns undefined when chat.appendStream rejects (delivery failure)", async () => {
360
+ const client = makeMockClient();
361
+ const { progress } = makeProgress({ client });
362
+ await progress.start();
363
+ client.chat.appendStream.mockRejectedValueOnce(new Error("append down"));
364
+ const delivered = await progress.deliverContent("Here is the answer.");
365
+ expect(delivered).toBeUndefined();
366
+ });
367
+
368
+ // AC #4: the completed card is explicitly sent status:"complete" — leaving
369
+ // it "in_progress" when the stream closes renders as an error/warning
370
+ // triangle even on success.
371
+ test("finish() sends a status:complete task_update when no deliverContent() call already closed the card (AC #4)", async () => {
372
+ const { progress, client } = makeProgress();
373
+ await progress.start();
374
+ client.chat.appendStream.mockClear();
375
+ await progress.finish();
376
+ expect(client.chat.appendStream).toHaveBeenCalledTimes(1);
377
+ const call = client.chat.appendStream.mock.calls[0]?.[0] as {
378
+ chunks: Array<Record<string, unknown>>;
379
+ };
380
+ expect(taskUpdateChunk(call)?.status).toBe("complete");
381
+ });
382
+
383
+ // STS2-3.1: a silent/suppressed response still needs its dangling
384
+ // in_progress card closed, but "Done" would misleadingly imply a real
385
+ // reply was posted — finish({ silent: true }) uses "Ack" instead.
386
+ test("finish({ silent: true }) sends a status:complete task_update titled 'Ack' (STS2-3.1)", async () => {
387
+ const { progress, client } = makeProgress();
388
+ await progress.start();
389
+ client.chat.appendStream.mockClear();
390
+ await progress.finish({ silent: true });
391
+ expect(client.chat.appendStream).toHaveBeenCalledTimes(1);
392
+ const call = client.chat.appendStream.mock.calls[0]?.[0] as {
393
+ chunks: Array<Record<string, unknown>>;
394
+ };
395
+ const chunk = taskUpdateChunk(call);
396
+ expect(chunk?.title).toBe("Ack");
397
+ expect(chunk?.status).toBe("complete");
398
+ });
399
+
400
+ // The "Done" title on the terminal card was getting prepended to the
401
+ // response preview in Slack's UI, adding confusion — the non-silent
402
+ // completion title is now empty (STS-2.1 AC #4). SILENT_COMPLETE_TITLE
403
+ // ("Ack") is unchanged — see the finish({ silent: true }) test above.
404
+ test("finish() and finish({ silent: false }) send an empty completion title, not 'Done' (STS-2.1 AC #4)", async () => {
405
+ for (const opts of [undefined, { silent: false }] as const) {
406
+ const { progress, client } = makeProgress();
407
+ await progress.start();
408
+ client.chat.appendStream.mockClear();
409
+ await progress.finish(opts);
410
+ const call = client.chat.appendStream.mock.calls[0]?.[0] as {
411
+ chunks: Array<Record<string, unknown>>;
412
+ };
413
+ const chunk = taskUpdateChunk(call);
414
+ expect(chunk?.title).toBe("");
415
+ }
416
+ });
417
+
418
+ test("finish() does not send a duplicate status:complete update when deliverContent() already closed the card", async () => {
419
+ const { progress, client } = makeProgress();
420
+ await progress.start();
421
+ await progress.deliverContent("Here is the answer.");
422
+ client.chat.appendStream.mockClear();
423
+ await progress.finish();
424
+ expect(client.chat.appendStream).not.toHaveBeenCalled();
425
+ });
426
+
427
+ test("finish() closes the stream via chat.stopStream", async () => {
428
+ const { progress, client } = makeProgress();
429
+ await progress.start();
430
+ await progress.finish();
431
+ expect(client.chat.stopStream).toHaveBeenCalledWith(
432
+ expect.objectContaining({
433
+ channel: CHANNEL,
434
+ thread_ts: THREAD_TS,
435
+ ts: "stream.ts.1",
436
+ }),
437
+ );
438
+ });
439
+
440
+ // AC #5: session_status defaults to "active" (the genuine last-to-finish
441
+ // message of a burst), but "processing" when the caller signals other
442
+ // messages are still in flight — chat.stopStream's session_status is
443
+ // per-thread, not per-message.
444
+ test("finish() closes with session_status 'active' by default", async () => {
445
+ const { progress, client } = makeProgress();
446
+ await progress.start();
447
+ await progress.finish({ stillInFlight: false });
448
+ expect(client.chat.stopStream).toHaveBeenCalledWith(
449
+ expect.objectContaining({ session_status: "active" }),
450
+ );
451
+ });
452
+
453
+ test("finish() closes with session_status 'processing' when other messages are still in flight (AC #5)", async () => {
454
+ const { progress, client } = makeProgress();
455
+ await progress.start();
456
+ await progress.finish({ stillInFlight: true });
457
+ expect(client.chat.stopStream).toHaveBeenCalledWith(
458
+ expect.objectContaining({ session_status: "processing" }),
459
+ );
460
+ });
461
+
462
+ // ISW-1.1 (AC #2): finish() always calls setStatus as the authoritative
463
+ // resolution of the "is working" indicator — independent of whether a
464
+ // stream ever opened — AND still calls stopStream() whenever a stream did
465
+ // open (the two are independent, not either/or).
466
+ test("finish() always calls setStatus with session_status 'active' by default, alongside stopStream() when a stream opened (AC #2)", async () => {
467
+ const { progress, client } = makeProgress();
468
+ await progress.start();
469
+ await progress.finish();
470
+ expect(client.agents.sessions.setStatus).toHaveBeenCalledWith({
471
+ channel_id: CHANNEL,
472
+ thread_ts: THREAD_TS,
473
+ status: "active",
474
+ });
475
+ expect(client.chat.stopStream).toHaveBeenCalledWith(
476
+ expect.objectContaining({
477
+ channel: CHANNEL,
478
+ thread_ts: THREAD_TS,
479
+ ts: "stream.ts.1",
480
+ session_status: "active",
481
+ }),
482
+ );
483
+ });
484
+
485
+ test("finish({ stillInFlight: true }) calls setStatus with 'processing', alongside stopStream() when a stream opened (AC #2)", async () => {
486
+ const { progress, client } = makeProgress();
487
+ await progress.start();
488
+ await progress.finish({ stillInFlight: true });
489
+ expect(client.agents.sessions.setStatus).toHaveBeenCalledWith({
490
+ channel_id: CHANNEL,
491
+ thread_ts: THREAD_TS,
492
+ status: "processing",
493
+ });
494
+ expect(client.chat.stopStream).toHaveBeenCalledWith(
495
+ expect.objectContaining({ session_status: "processing" }),
496
+ );
497
+ });
498
+ });
499
+
500
+ // ─── errors-swallowed ────────────────────────────────────────────────────────────
501
+
502
+ describe("SlackProgress — errors swallowed", () => {
503
+ let savedWarn: typeof console.warn;
504
+ let warnCalls: unknown[][];
505
+
506
+ beforeEach(() => {
507
+ savedWarn = console.warn;
508
+ warnCalls = [];
509
+ console.warn = (...args: unknown[]) => {
510
+ warnCalls.push(args);
511
+ };
512
+ });
513
+
514
+ afterEach(() => {
515
+ console.warn = savedWarn;
516
+ });
517
+
518
+ test("start() does not throw when the client has no chat namespace, and warns", async () => {
519
+ const setStatus = mock(async (_args: unknown) => {});
520
+ const progress = new SlackProgress({
521
+ client: { agents: { sessions: { setStatus } } },
522
+ channel: CHANNEL,
523
+ threadTs: THREAD_TS,
524
+ isDM: true,
525
+ });
526
+ await expect(progress.start()).resolves.toBeUndefined();
527
+ // setStatus (eager, ISW-1.1) succeeds first, then startStream fails (no
528
+ // chat namespace), then the seed-card append also fails for the same
529
+ // reason — all three swallowed/warned as appropriate.
530
+ expect(setStatus).toHaveBeenCalledWith({
531
+ channel_id: CHANNEL,
532
+ thread_ts: THREAD_TS,
533
+ status: "processing",
534
+ });
535
+ expect(warnCalls).toContainEqual([
536
+ "[slack-progress] startStream failed:",
537
+ "undefined is not an object (evaluating 'this.client.chat.startStream')",
538
+ ]);
539
+ expect(warnCalls).toContainEqual([
540
+ "[slack-progress] appendStream(Thinking…) failed:",
541
+ "undefined is not an object (evaluating 'this.client.chat.appendStream')",
542
+ ]);
543
+ });
544
+
545
+ test("start() does not throw when chat.startStream rejects, and warns", async () => {
546
+ const client = makeMockClient();
547
+ client.chat.startStream.mockRejectedValueOnce(new Error("stream down"));
548
+ const { progress } = makeProgress({ client });
549
+ await expect(progress.start()).resolves.toBeUndefined();
550
+ expect(warnCalls).toContainEqual([
551
+ "[slack-progress] startStream failed:",
552
+ "stream down",
553
+ ]);
554
+ });
555
+
556
+ // ISW-1.1 / AC #3: a setStatus failure at start() — the new eager call —
557
+ // must be caught and logged without preventing startStream() or the seed
558
+ // appendTaskUpdate from proceeding. Mirrors the existing
559
+ // finish()'s-setStatus-fallback-failure test pattern below.
560
+ test("start() does not throw when agents.sessions.setStatus rejects, and warns — startStream()/seed card still proceed (AC #3)", async () => {
561
+ const client = makeMockClient();
562
+ client.agents.sessions.setStatus.mockRejectedValueOnce(
563
+ new Error("setStatus down"),
564
+ );
565
+ const { progress } = makeProgress({ client });
566
+ await expect(progress.start()).resolves.toBeUndefined();
567
+ expect(warnCalls).toContainEqual([
568
+ "[slack-progress] setStatus failed:",
569
+ "setStatus down",
570
+ ]);
571
+ // startStream() and the seed card still ran despite the setStatus
572
+ // failure.
573
+ expect(client.chat.startStream).toHaveBeenCalledTimes(1);
574
+ expect(client.chat.appendStream).toHaveBeenCalledTimes(1);
575
+ });
576
+
577
+ // Same as above, but the client is missing the `agents` namespace
578
+ // entirely — the property access itself throws synchronously, which must
579
+ // also be caught without blocking startStream()/the seed card.
580
+ test("start() does not throw when the client has no agents namespace at all, and warns — startStream()/seed card still proceed (AC #3)", async () => {
581
+ const { chat } = makeMockClient();
582
+ const client = { chat } as unknown as ReturnType<typeof makeMockClient>;
583
+ const { progress } = makeProgress({ client });
584
+ await expect(progress.start()).resolves.toBeUndefined();
585
+ expect(warnCalls).toContainEqual([
586
+ "[slack-progress] setStatus failed:",
587
+ "undefined is not an object (evaluating 'this.client.agents.sessions')",
588
+ ]);
589
+ expect(client.chat.startStream).toHaveBeenCalledTimes(1);
590
+ expect(client.chat.appendStream).toHaveBeenCalledTimes(1);
591
+ });
592
+
593
+ test("onProgress does not throw when the client has no chat namespace, and warns", async () => {
594
+ const progress = new SlackProgress({
595
+ client: { agents: { sessions: { setStatus: mock(async () => {}) } } },
596
+ channel: CHANNEL,
597
+ threadTs: THREAD_TS,
598
+ isDM: true,
599
+ });
600
+ expect(() => progress.onProgress({}, "reading")).not.toThrow();
601
+ expect(warnCalls).toContainEqual([
602
+ "[slack-progress] appendStream(Reading files…) failed:",
603
+ "undefined is not an object (evaluating 'this.client.chat.appendStream')",
604
+ ]);
605
+ });
606
+
607
+ test("onProgress warns (async) when chat.appendStream rejects", async () => {
608
+ const client = makeMockClient();
609
+ const { progress } = makeProgress({ client });
610
+ await progress.start();
611
+ client.chat.appendStream.mockRejectedValueOnce(new Error("append down"));
612
+ expect(() => progress.onProgress({}, "reading")).not.toThrow();
613
+ // appendStream is fire-and-forget (sync callback) — let the rejection's
614
+ // .catch() microtask run before asserting.
615
+ await Promise.resolve();
616
+ await Promise.resolve();
617
+ expect(warnCalls).toContainEqual([
618
+ "[slack-progress] appendStream(Reading files…) failed:",
619
+ "append down",
620
+ ]);
621
+ });
622
+
623
+ // ISW-1.1 / AC #2: a stream that never opened (streamTs stayed undefined —
624
+ // here because the client has no chat namespace at all) must not fall
625
+ // through to a doomed chat.stopStream call (guaranteed to fail with no
626
+ // valid ts) — finish()'s now-unconditional agents.sessions.setStatus call
627
+ // still runs regardless, so the "is working" indicator can still be
628
+ // cleared.
629
+ test("finish() calls agents.sessions.setStatus when the client has no chat namespace (stream never opened) (AC #2)", async () => {
630
+ const setStatus = mock(async (_args: unknown) => {});
631
+ const progress = new SlackProgress({
632
+ client: { agents: { sessions: { setStatus } } },
633
+ channel: CHANNEL,
634
+ threadTs: THREAD_TS,
635
+ isDM: true,
636
+ });
637
+ await expect(progress.finish()).resolves.toBeUndefined();
638
+ expect(setStatus).toHaveBeenCalledWith({
639
+ channel_id: CHANNEL,
640
+ thread_ts: THREAD_TS,
641
+ status: "active",
642
+ });
643
+ });
644
+
645
+ // Same path, but the setStatus call itself also fails — must still not
646
+ // throw, and warns instead of silently swallowing it.
647
+ test("finish()'s setStatus call failure does not throw, and warns", async () => {
648
+ const setStatus = mock(async (_args: unknown) => {
649
+ throw new Error("setStatus down");
650
+ });
651
+ const progress = new SlackProgress({
652
+ client: { agents: { sessions: { setStatus } } },
653
+ channel: CHANNEL,
654
+ threadTs: THREAD_TS,
655
+ isDM: true,
656
+ });
657
+ await expect(progress.finish()).resolves.toBeUndefined();
658
+ expect(warnCalls).toContainEqual([
659
+ "[slack-progress] setStatus failed:",
660
+ "setStatus down",
661
+ ]);
662
+ });
663
+
664
+ // The client is missing the `agents` namespace entirely too — must not
665
+ // throw synchronously on the property access either.
666
+ test("finish()'s setStatus call does not throw when the client has no agents namespace at all, and warns", async () => {
667
+ const progress = new SlackProgress({
668
+ client: {},
669
+ channel: CHANNEL,
670
+ threadTs: THREAD_TS,
671
+ isDM: true,
672
+ });
673
+ await expect(progress.finish()).resolves.toBeUndefined();
674
+ expect(warnCalls).toContainEqual([
675
+ "[slack-progress] setStatus failed:",
676
+ "undefined is not an object (evaluating 'this.client.agents.sessions')",
677
+ ]);
678
+ });
679
+
680
+ test("finish() does not throw when chat.stopStream rejects, and warns", async () => {
681
+ const client = makeMockClient();
682
+ client.chat.stopStream.mockRejectedValueOnce(new Error("stop down"));
683
+ const { progress } = makeProgress({ client });
684
+ await progress.start();
685
+ await expect(progress.finish()).resolves.toBeUndefined();
686
+ expect(warnCalls).toContainEqual([
687
+ "[slack-progress] stopStream failed:",
688
+ "stop down",
689
+ ]);
690
+ });
691
+
692
+ test("deliverContent() does not throw when chat.appendStream rejects, and warns", async () => {
693
+ const client = makeMockClient();
694
+ const { progress } = makeProgress({ client });
695
+ await progress.start();
696
+ client.chat.appendStream.mockRejectedValueOnce(new Error("deliver down"));
697
+ await expect(
698
+ progress.deliverContent("final answer"),
699
+ ).resolves.toBeUndefined();
700
+ expect(warnCalls).toContainEqual([
701
+ "[slack-progress] deliverContent failed:",
702
+ "deliver down",
703
+ ]);
704
+ });
705
+ });
706
+
707
+ // ─── streamKey() / markExternallyStopped() (AGS-1.1) ──────────────────────────
708
+ // Slack's agent_session_stopped event carries a channel + streaming_message_ts
709
+ // pair identifying the dead stream. streamKey() exposes the same "channel:ts"
710
+ // shape once startStream() has resolved, so slack.ts's closure-scoped registry
711
+ // can key a live SlackProgress instance by it. markExternallyStopped() then
712
+ // makes every further stream call (appendStream/deliverContent/reportError/
713
+ // stopStream) a no-op instead of an API call — mirroring openclaw's
714
+ // applySlackStreamStop pattern — since Slack has already discarded the stream
715
+ // server-side and any further write would fail with message_not_in_streaming_state.
716
+
717
+ describe("SlackProgress.streamKey()", () => {
718
+ test("undefined before start() (stream not yet opened)", () => {
719
+ const { progress } = makeProgress();
720
+ expect(progress.streamKey()).toBeUndefined();
721
+ });
722
+
723
+ test("returns `channel:streamTs` once the stream has opened", async () => {
724
+ const { progress } = makeProgress();
725
+ await progress.start();
726
+ expect(progress.streamKey()).toBe(`${CHANNEL}:stream.ts.1`);
727
+ });
728
+
729
+ test("undefined when startStream failed to open (no streamTs)", async () => {
730
+ const client = makeMockClient();
731
+ client.chat.startStream.mockRejectedValueOnce(new Error("stream down"));
732
+ const { progress } = makeProgress({ client });
733
+ await progress.start();
734
+ expect(progress.streamKey()).toBeUndefined();
735
+ });
736
+ });
737
+
738
+ describe("SlackProgress.getThreadTs()", () => {
739
+ test("returns the bound thread_ts, distinct from streamKey()'s stream ts", async () => {
740
+ const { progress } = makeProgress();
741
+ await progress.start();
742
+ expect(progress.getThreadTs()).toBe(THREAD_TS);
743
+ expect(progress.streamKey()).toBe(`${CHANNEL}:stream.ts.1`);
744
+ });
745
+ });
746
+
747
+ describe("SlackProgress.markExternallyStopped()", () => {
748
+ test("appendStream (via onProgress) becomes a no-op after the mark", async () => {
749
+ const client = makeMockClient();
750
+ const { progress } = makeProgress({ client });
751
+ await progress.start();
752
+ client.chat.appendStream.mockClear();
753
+
754
+ progress.markExternallyStopped();
755
+ progress.onProgress({}, "reading");
756
+ await Promise.resolve();
757
+ await Promise.resolve();
758
+
759
+ expect(client.chat.appendStream).not.toHaveBeenCalled();
760
+ });
761
+
762
+ test("deliverContent() becomes a no-op (returns undefined, no API call) after the mark", async () => {
763
+ const client = makeMockClient();
764
+ const { progress } = makeProgress({ client });
765
+ await progress.start();
766
+ client.chat.appendStream.mockClear();
767
+
768
+ progress.markExternallyStopped();
769
+ const result = await progress.deliverContent("final answer");
770
+
771
+ expect(result).toBeUndefined();
772
+ expect(client.chat.appendStream).not.toHaveBeenCalled();
773
+ });
774
+
775
+ test("reportError() becomes a no-op after the mark", async () => {
776
+ const client = makeMockClient();
777
+ const { progress } = makeProgress({ client });
778
+ await progress.start();
779
+ client.chat.appendStream.mockClear();
780
+
781
+ progress.markExternallyStopped();
782
+ progress.reportError();
783
+
784
+ expect(client.chat.appendStream).not.toHaveBeenCalled();
785
+ });
786
+
787
+ test("finish()'s stopStream call becomes a no-op after the mark", async () => {
788
+ const client = makeMockClient();
789
+ const { progress } = makeProgress({ client });
790
+ await progress.start();
791
+
792
+ progress.markExternallyStopped();
793
+ await progress.finish();
794
+
795
+ expect(client.chat.stopStream).not.toHaveBeenCalled();
796
+ });
797
+
798
+ test("finish() still resolves cleanly (no throw) after the mark", async () => {
799
+ const client = makeMockClient();
800
+ const { progress } = makeProgress({ client });
801
+ await progress.start();
802
+
803
+ progress.markExternallyStopped();
804
+ await expect(progress.finish()).resolves.toBeUndefined();
805
+ });
806
+ });