@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,510 @@
1
+ /**
2
+ * agent/src/runtime/slack-progress.ts
3
+ *
4
+ * Live Thinking Steps stream driver for Slack thread replies.
5
+ *
6
+ * Drives a `chat.startStream` / `chat.appendStream` / `chat.stopStream`
7
+ * Thinking Steps stream (STS2-1.1 — replacing STS-1.1/PR #3034's broken
8
+ * first cut, whose `{type: "task_update", text}` chunk shape was rejected by
9
+ * Slack's real API on every single call, root-causing a permanently blank
10
+ * stream; TSD-1.1 then removed the feature flag that gated it, making
11
+ * streaming the only mode):
12
+ *
13
+ * - `agents.sessions.setStatus` (Slack's Agent Sessions API) is called
14
+ * eagerly and unconditionally — once at the top of `start()`, before
15
+ * `startStream()` even runs, and once at the end of `finish()` — rather
16
+ * than treated as a stream-failure-only fallback. `chat.startStream`'s
17
+ * `recipient_team_id`/`recipient_user_id` requirement (see below) gates
18
+ * it from firing in channels until the user opens the message's thread,
19
+ * an inherent Slack UI limitation that channel @mentions hit but DMs
20
+ * don't; `setStatus` needs only `channel_id`/`thread_ts` and Slack's own
21
+ * docs say to call it directly when work begins, so calling it eagerly
22
+ * gets the native "is working" indicator showing immediately for both
23
+ * DMs and channel mentions instead of waiting on the stream (ISW-1.1).
24
+ * The two calls are independent, not either/or: `finish()` always calls
25
+ * `setStatus` AND still calls `stopStream()` whenever a stream did open.
26
+ * - `start()` opens the stream and immediately seeds it with an
27
+ * `in_progress` task_update card (no blank gap before the first real
28
+ * phase transition), titled "Thinking…" or "Queued…" depending on the
29
+ * caller-supplied `queued` flag (see `ThreadStatusTracker`).
30
+ * - Every task_update chunk — the seed card, each phase transition from
31
+ * `onProgress`, and the terminal card from `deliverContent()`/`finish()`
32
+ * — uses the `{type, id, title, status}` shape with ONE stable `id`
33
+ * reused for the life of the run: a fresh id per update stacks a
34
+ * separate card in Slack's timeline UI instead of updating the existing
35
+ * one in place (confirmed live, a second STS-1.1 bug). `status` is
36
+ * `"in_progress"` for the seed/phase cards and `"complete"` for the
37
+ * terminal one — leaving the final card `"in_progress"` when the stream
38
+ * closes renders as an error/warning triangle even on success.
39
+ * - The final reply text is delivered via `deliverContent()` as its own
40
+ * `markdown_text` chunk (alongside the terminal `status: "complete"`
41
+ * task_update) through `chat.appendStream` — NOT via `chat.stopStream`'s
42
+ * top-level `markdown_text` param, which Slack rejects with
43
+ * `streaming_mode_mismatch` once `task_display_mode: "timeline"` locks
44
+ * the stream into chunk-based mode.
45
+ * - `finish()` passes `session_status` through to `chat.stopStream`
46
+ * ("active" for the genuine last-to-finish message of a burst,
47
+ * "processing" while others are still in flight — stopStream's
48
+ * `session_status` defaults to "active" and is per-thread, not
49
+ * per-message, so without this the first-to-finish message of a burst
50
+ * would prematurely dismiss the "is working" indicator for the whole
51
+ * thread).
52
+ * - `finish()` always closes any still-open card before stopping the
53
+ * stream, even when the run's reply was suppressed (e.g. a [silent]
54
+ * marker resolved the response and deliverContent() was never called) —
55
+ * otherwise the seed card's `in_progress` state is left dangling in the
56
+ * channel forever (STS2-3.1). The fallback title is empty (STS-2.1
57
+ * AC #4 — "Done" was getting prepended to the response preview in
58
+ * Slack's UI), unless the caller passes `finish({ silent: true })`, in
59
+ * which case it's "Ack" — a distinct, deliberately terse label so the
60
+ * card doesn't imply a real reply was posted, while still giving a
61
+ * visual signal that Bodhi processed the message and chose not to
62
+ * reply, rather than something having silently broken.
63
+ * - `startStream()` passes `recipient_team_id`/`recipient_user_id`
64
+ * whenever the conversation isn't a DM (see `SlackProgressOptions.isDM`)
65
+ * — Slack's own SDK types document both as "required when starting a
66
+ * streaming conversation outside of a DM"; omitting them was the
67
+ * STS-2.1 root cause (`missing_recipient_team_id` on every channel/group
68
+ * thread). `finish()` calls `agents.sessions.setStatus` unconditionally
69
+ * (ISW-1.1) — not only as a fallback when `startStream()` never obtained
70
+ * a `ts` — so a stream-open failure (for any reason) can never
71
+ * permanently strand the "is working" indicator, and the indicator is
72
+ * authoritatively resolved by `setStatus` even when a stream did open
73
+ * and `stopStream()` also runs.
74
+ *
75
+ * Repeated identical phases collapse into a single appendStream call,
76
+ * mirroring the phase-collapse `_consumeStream` already does upstream in
77
+ * claude.ts before invoking `onProgress` — SlackProgress collapses
78
+ * independently too, since its own onProgress is unit-tested in isolation
79
+ * from that upstream collapsing.
80
+ *
81
+ * Every Slack API call is wrapped in try/catch — a progress failure must
82
+ * never throw or break the real reply. This covers a client that lacks the
83
+ * `chat` namespace entirely (property access throws synchronously), not
84
+ * just a call that rejects. `onProgress` is a *synchronous* callback (see
85
+ * `ProgressCallback` in claude.ts), so `chat.appendStream` is fired without
86
+ * `await`: the property-access + call is wrapped in a synchronous try/catch
87
+ * (for the missing-namespace case) and the returned promise gets a trailing
88
+ * `.catch()` (for the async-rejection case). `deliverContent()` IS awaited
89
+ * (its caller needs to know whether to fall back to say()/blocksConverter),
90
+ * so it awaits the promise directly inside its own try/catch instead.
91
+ */
92
+
93
+ import type { ProgressPhase } from "@app-vitals/agent-lib/progress-phases";
94
+ import { PROGRESS_LABELS } from "@app-vitals/agent-lib/progress-phases";
95
+ import type { ModelUsage } from "./claude.ts";
96
+
97
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt client type is complex
98
+ type SlackClient = any;
99
+
100
+ interface SlackProgressOptions {
101
+ client: SlackClient;
102
+ channel: string;
103
+ threadTs: string;
104
+ /**
105
+ * True when this run's thread is a DM. Slack's own SDK types document
106
+ * `recipient_team_id`/`recipient_user_id` on `chat.startStream` as
107
+ * "required when starting a streaming conversation outside of a DM" —
108
+ * omitting them in a channel/group thread makes every call fail with
109
+ * `missing_recipient_team_id` (STS-2.1). Passing them for an actual DM
110
+ * isn't meaningful (the DM already fully identifies the recipient), so
111
+ * `startStream()` omits both fields entirely when `isDM` is true,
112
+ * regardless of whether `recipientTeamId`/`recipientUserId` were supplied.
113
+ */
114
+ isDM: boolean;
115
+ /**
116
+ * The triggering user's Slack ID — required by `chat.startStream()` for
117
+ * every non-DM stream (see `isDM`). Ignored (and may be omitted) for DMs.
118
+ */
119
+ recipientUserId?: string;
120
+ /**
121
+ * The bot's own workspace team ID. Resolved once by the caller via
122
+ * `client.auth.test()` and cached (see `createSlackApp()` in slack.ts) —
123
+ * required by `chat.startStream()` for every non-DM stream (see `isDM`).
124
+ * Ignored (and may be omitted) for DMs.
125
+ */
126
+ recipientTeamId?: string;
127
+ }
128
+
129
+ interface SlackProgressStartOptions {
130
+ /**
131
+ * True when the caller (ThreadStatusTracker.enter()'s inverse — see
132
+ * slack-thread-status-tracker.ts) signals another message on this thread
133
+ * is already in flight. Only changes the seed card's title ("Queued…"
134
+ * instead of "Thinking…") — every message still gets its own independent
135
+ * start()/stream lifecycle regardless of this flag.
136
+ */
137
+ queued?: boolean;
138
+ }
139
+
140
+ export interface SlackProgressFinishOptions {
141
+ /**
142
+ * True when the caller (ThreadStatusTracker.exit()'s inverse) signals
143
+ * other messages on this thread are still in flight. Only changes the
144
+ * session_status stopStream closes with ("processing" instead of
145
+ * "active") — every message still gets its own independent
146
+ * finish()/stream-close call regardless of this flag.
147
+ */
148
+ stillInFlight?: boolean;
149
+ /**
150
+ * True when the caller suppressed the reply (e.g. a [silent] marker
151
+ * resolved the run with nothing to post). When the run's card hasn't
152
+ * already been closed by deliverContent(), finish() still must close the
153
+ * stream's dangling in_progress card (STS2-3.1) — but titles it "Ack"
154
+ * instead of the generic "Done", since "Done" implies a real reply went
155
+ * out. Has no effect once deliverContent() has already closed the card.
156
+ */
157
+ silent?: boolean;
158
+ }
159
+
160
+ /** Text used for the seed task_update card's title. Always ends in "…". */
161
+ function seedTitle(opts: SlackProgressStartOptions | undefined): string {
162
+ return opts?.queued ? "Queued…" : "Thinking…";
163
+ }
164
+
165
+ /**
166
+ * Title for the terminal task_update card. Never ends in "…" (AC #4).
167
+ * Empty — not "Done" — because the "Done" title was getting prepended to
168
+ * the response preview in Slack's UI, adding confusion (STS-2.1 AC #4).
169
+ * Slack's TaskUpdateChunk type (`@slack/types`) declares `title: string`
170
+ * with no documented minLength constraint, so an empty string is used
171
+ * directly; if Slack's live API ever proves to reject a true empty string,
172
+ * fall back to a single space (`" "`) instead.
173
+ */
174
+ const COMPLETE_TITLE = "";
175
+
176
+ /** Title for the terminal card on a thrown run (STS2-4.1 AC #1). */
177
+ const ERROR_TITLE = "Error";
178
+
179
+ /**
180
+ * Title for the terminal task_update card when finish() closes it for a
181
+ * suppressed (e.g. [silent]-marker) response — see SlackProgressFinishOptions
182
+ * (STS2-3.1). Distinct from COMPLETE_TITLE so the card doesn't imply a real
183
+ * reply was posted, while still giving a visual signal the run completed
184
+ * rather than having silently broken.
185
+ */
186
+ const SILENT_COMPLETE_TITLE = "Ack";
187
+
188
+ /**
189
+ * Drives the live Thinking Steps stream for a single Claude run. One
190
+ * instance per run — construct it where "processing" used to be set, pass
191
+ * `progress.onProgress` (bound) as the runner's third arg, try
192
+ * `progress.deliverContent()` before falling back to say()/blocksConverter,
193
+ * and call `progress.finish()` in the handler's completion path.
194
+ */
195
+ export class SlackProgress {
196
+ private readonly client: SlackClient;
197
+ private readonly channel: string;
198
+ private readonly threadTs: string;
199
+ private readonly isDM: boolean;
200
+ private readonly recipientUserId: string | undefined;
201
+ private readonly recipientTeamId: string | undefined;
202
+ private streamTs: string | undefined;
203
+ private lastFiredPhase: ProgressPhase | undefined;
204
+ // One stable id for every task_update chunk in this run's stream — a
205
+ // fresh id per update stacks a separate card in Slack's timeline UI
206
+ // instead of updating the existing one in place (AC #1).
207
+ private readonly taskId: string;
208
+ // Set once the terminal (status:"complete") card has been sent, whether
209
+ // via deliverContent() or finish()'s own fallback — prevents finish()
210
+ // from sending a redundant second "complete" update.
211
+ private completed = false;
212
+ // Set by markExternallyStopped() when Slack's agent_session_stopped event
213
+ // fires for this run's stream (AGS-1.1). Once true, every further
214
+ // appendStream/stopStream call becomes a no-op instead of an API call —
215
+ // Slack has already discarded the stream server-side, so any further
216
+ // write would just fail with message_not_in_streaming_state. Mirrors
217
+ // openclaw's applySlackStreamStop pattern.
218
+ private externallyStopped = false;
219
+
220
+ constructor(opts: SlackProgressOptions) {
221
+ this.client = opts.client;
222
+ this.channel = opts.channel;
223
+ this.threadTs = opts.threadTs;
224
+ this.isDM = opts.isDM;
225
+ this.recipientUserId = opts.recipientUserId;
226
+ this.recipientTeamId = opts.recipientTeamId;
227
+ this.taskId = `thinking-steps-${this.channel}-${this.threadTs}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
228
+ }
229
+
230
+ /**
231
+ * Call once, up front. Eagerly sets the session status to "processing" via
232
+ * agents.sessions.setStatus (ISW-1.1) before opening the Thinking Steps
233
+ * stream, so Slack's native "is working" indicator shows immediately —
234
+ * chat.startStream's recipient_team_id/recipient_user_id requirement (see
235
+ * SlackProgressOptions.isDM) means its own stream-status effect is gated
236
+ * behind the user opening the message's thread in a channel, an inherent
237
+ * Slack UI limitation channel @mentions hit but DMs don't. A setStatus
238
+ * failure here is caught and logged like every other Slack API call in
239
+ * this file — it must never block startStream() or the seed card below.
240
+ * Then opens the Thinking Steps stream and seeds it with an immediate
241
+ * in_progress card (AC #4).
242
+ */
243
+ async start(opts?: SlackProgressStartOptions): Promise<void> {
244
+ await this.setSessionStatus("processing");
245
+ await this.startStream();
246
+ this.appendTaskUpdate(seedTitle(opts), "in_progress");
247
+ }
248
+
249
+ /**
250
+ * The `channel:streaming_message_ts` key identifying this run's live
251
+ * Thinking Steps stream, matching the shape Slack's agent_session_stopped
252
+ * event carries (event.channel + event.streaming_message_ts) — used by
253
+ * createSlackApp()'s closure-scoped registry (AGS-1.1) to look up the
254
+ * SlackProgress instance a stopped-stream event refers to. Returns
255
+ * undefined before the stream has opened (flag off, start() not yet
256
+ * called, or startStream() failed to obtain a `ts`).
257
+ */
258
+ streamKey(): string | undefined {
259
+ if (!this.streamTs) return undefined;
260
+ return `${this.channel}:${this.streamTs}`;
261
+ }
262
+
263
+ /**
264
+ * This run's actual thread ts (as opposed to the stream's own message ts
265
+ * returned by streamKey()) — the value agents.sessions.setStatus expects
266
+ * for `thread_ts`. Used by createSlackApp()'s agent_session_stopped
267
+ * handler (AGS-1.1) to reset the correct thread's status once the
268
+ * matching SlackProgress instance has been found via streamKey().
269
+ */
270
+ getThreadTs(): string {
271
+ return this.threadTs;
272
+ }
273
+
274
+ /**
275
+ * Marks this run's stream as externally stopped by Slack (AGS-1.1) — call
276
+ * when the agent_session_stopped event fires for this instance's
277
+ * streamKey(). From this point on, appendTaskUpdate/deliverContent/
278
+ * reportError/stopStream are all no-ops: Slack has already discarded the
279
+ * stream server-side, so any further write would only fail with
280
+ * message_not_in_streaming_state. Idempotent and safe to call more than
281
+ * once.
282
+ */
283
+ markExternallyStopped(): void {
284
+ this.externallyStopped = true;
285
+ }
286
+
287
+ private async startStream(): Promise<void> {
288
+ // Whole call (including the `chat` property access) wrapped in
289
+ // try/catch — see the module-level doc comment.
290
+ try {
291
+ const resp = await this.client.chat.startStream({
292
+ channel: this.channel,
293
+ thread_ts: this.threadTs,
294
+ task_display_mode: "timeline",
295
+ // recipient_team_id/recipient_user_id are "required when starting a
296
+ // streaming conversation outside of a DM" per Slack's own SDK types
297
+ // — omitted entirely for DMs, where they're not required and may
298
+ // not even be meaningful (STS-2.1 AC #1/#2).
299
+ ...(this.isDM
300
+ ? {}
301
+ : {
302
+ recipient_team_id: this.recipientTeamId,
303
+ recipient_user_id: this.recipientUserId,
304
+ }),
305
+ });
306
+ this.streamTs = resp?.ts;
307
+ } catch (err) {
308
+ console.warn(
309
+ "[slack-progress] startStream failed:",
310
+ err instanceof Error ? err.message : String(err),
311
+ );
312
+ }
313
+ }
314
+
315
+ /**
316
+ * The callback passed as the Claude runner's third arg. Appends one
317
+ * Thinking Steps chunk per *distinct* phase transition — repeats of the
318
+ * same phase (or an undefined phase, i.e. a usage-only tick) are no-ops.
319
+ */
320
+ onProgress = (_usage: ModelUsage, phase?: ProgressPhase): void => {
321
+ if (phase === undefined) return;
322
+ if (phase === this.lastFiredPhase) return;
323
+ this.lastFiredPhase = phase;
324
+ this.appendTaskUpdate(PROGRESS_LABELS[phase], "in_progress");
325
+ };
326
+
327
+ // Fire-and-forget: onProgress is a synchronous callback (ProgressCallback
328
+ // is not async), so this can't be awaited. The property access + call is
329
+ // wrapped in a synchronous try/catch (client missing `chat` entirely), and
330
+ // the returned promise gets a trailing `.catch()` (async rejection).
331
+ //
332
+ // Always the {type, id, title, status} shape with this.taskId reused
333
+ // across every call — see the module-level doc comment for why the old
334
+ // {type, text} shape (and a fresh id per update) were the STS-1.1 bugs.
335
+ private appendTaskUpdate(
336
+ title: string,
337
+ status: "in_progress" | "complete" | "error",
338
+ ): void {
339
+ if (this.externallyStopped) return;
340
+ const label = `appendStream(${title})`;
341
+ try {
342
+ this.client.chat
343
+ .appendStream({
344
+ channel: this.channel,
345
+ thread_ts: this.threadTs,
346
+ ts: this.streamTs,
347
+ chunks: [{ type: "task_update", id: this.taskId, title, status }],
348
+ })
349
+ .catch((err: unknown) => {
350
+ console.warn(
351
+ `[slack-progress] ${label} failed:`,
352
+ err instanceof Error ? err.message : String(err),
353
+ );
354
+ });
355
+ } catch (err) {
356
+ console.warn(
357
+ `[slack-progress] ${label} failed:`,
358
+ err instanceof Error ? err.message : String(err),
359
+ );
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Delivers the final reply text through the Thinking Steps stream as its
365
+ * own markdown_text chunk, alongside a status:"complete" task_update
366
+ * closing out the run's card (AC #2) — NOT via chat.stopStream's
367
+ * top-level markdown_text param, which Slack rejects with
368
+ * streaming_mode_mismatch once task_display_mode:"timeline" locks the
369
+ * stream into chunk-based mode.
370
+ *
371
+ * Returns `true` on success, or `undefined` whenever delivery fails or
372
+ * streaming is unavailable (stream never opened, or the append call
373
+ * itself rejects) — the caller MUST fall back to say()/blocksConverter in
374
+ * that case; the reply must never be silently dropped.
375
+ */
376
+ async deliverContent(text: string): Promise<true | undefined> {
377
+ if (!this.streamTs) return undefined;
378
+ if (this.externallyStopped) return undefined;
379
+ try {
380
+ await this.client.chat.appendStream({
381
+ channel: this.channel,
382
+ thread_ts: this.threadTs,
383
+ ts: this.streamTs,
384
+ chunks: [
385
+ { type: "markdown_text", text },
386
+ {
387
+ type: "task_update",
388
+ id: this.taskId,
389
+ title: COMPLETE_TITLE,
390
+ status: "complete",
391
+ },
392
+ ],
393
+ });
394
+ this.completed = true;
395
+ return true;
396
+ } catch (err) {
397
+ console.warn(
398
+ "[slack-progress] deliverContent failed:",
399
+ err instanceof Error ? err.message : String(err),
400
+ );
401
+ return undefined;
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Call from the handler's catch block when the run throws, before
407
+ * `finish()` closes the stream (STS2-4.1 AC #1). Marks the run's card
408
+ * `status: "error"` so a thrown run doesn't leave the card stuck in
409
+ * whatever `in_progress` state it was last in. This is a UI-state signal
410
+ * only — the caller's existing `sentryClient.captureException(err)` call
411
+ * remains the single log point for the error; this method does not log.
412
+ *
413
+ * No-op (matching `deliverContent()`'s guard) when no stream is open —
414
+ * never throws, never calls `chat.appendStream` in that case. Sets the
415
+ * same `completed` flag `deliverContent()` sets, so `finish()`'s "only if
416
+ * !this.completed" guard doesn't send a redundant `status: "complete"`
417
+ * card after the error card.
418
+ */
419
+ reportError(): void {
420
+ if (!this.streamTs) return;
421
+ if (this.externallyStopped) return;
422
+ this.appendTaskUpdate(ERROR_TITLE, "error");
423
+ this.completed = true;
424
+ }
425
+
426
+ /**
427
+ * Call in the handler's completion path (finally block or equivalent).
428
+ * Closes out the run's card with a final status:"complete" update (unless
429
+ * deliverContent() already sent one), then unconditionally sets the final
430
+ * session status via agents.sessions.setStatus (ISW-1.1) — the
431
+ * authoritative resolution of Slack's native "is working" indicator,
432
+ * independent of whether a stream ever opened — AND, whenever a stream
433
+ * did open (this.streamTs is set), also closes it via stopStream, passing
434
+ * the same session_status through. session_status defaults to "active"
435
+ * and is per-thread, not per-message, so the caller must pass
436
+ * `stillInFlight: true` for every message except the genuine last one to
437
+ * finish in a burst (AC #5). The card's title is empty (COMPLETE_TITLE),
438
+ * unless `opts.silent` signals the reply was suppressed
439
+ * (e.g. a [silent] marker), in which case it's titled "Ack" instead
440
+ * (STS2-3.1) — closing the dangling in_progress card either way so no
441
+ * in-progress indicator is left visible in the channel forever.
442
+ */
443
+ async finish(opts?: SlackProgressFinishOptions): Promise<void> {
444
+ const sessionStatus = opts?.stillInFlight ? "processing" : "active";
445
+ if (!this.completed) {
446
+ const title = opts?.silent ? SILENT_COMPLETE_TITLE : COMPLETE_TITLE;
447
+ this.appendTaskUpdate(title, "complete");
448
+ }
449
+ // Unconditional and authoritative (ISW-1.1) — no longer gated behind
450
+ // "only when startStream() never opened" (see setSessionStatus's doc
451
+ // comment). Independent of stopStream() below, not either/or.
452
+ await this.setSessionStatus(sessionStatus);
453
+ if (this.streamTs) {
454
+ await this.stopStream(sessionStatus);
455
+ }
456
+ }
457
+
458
+ private async stopStream(
459
+ sessionStatus: "active" | "processing",
460
+ ): Promise<void> {
461
+ if (this.externallyStopped) return;
462
+ try {
463
+ await this.client.chat.stopStream({
464
+ channel: this.channel,
465
+ thread_ts: this.threadTs,
466
+ ts: this.streamTs,
467
+ session_status: sessionStatus,
468
+ });
469
+ } catch (err) {
470
+ console.warn(
471
+ "[slack-progress] stopStream failed:",
472
+ err instanceof Error ? err.message : String(err),
473
+ );
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Shared helper for both eager call sites (ISW-1.1): start() calls this
479
+ * with a fixed "processing" before startStream() even runs, so Slack's
480
+ * native "is working" indicator shows immediately regardless of whether
481
+ * chat.startStream ever succeeds; finish() calls this unconditionally
482
+ * (independent of whether a stream opened) as the authoritative
483
+ * resolution of that indicator, alongside — not instead of — stopStream()
484
+ * whenever a stream did open. No longer a stream-failure-only fallback.
485
+ * Needs only channel_id/thread_ts (no recipient_team_id/recipient_user_id,
486
+ * unlike chat.startStream in non-DM channels — see SlackProgressOptions.
487
+ * isDM), and never throws: every Slack API call in this file is wrapped in
488
+ * try/catch, including the property access itself (a client missing the
489
+ * `agents` namespace entirely throws synchronously on access, not just on
490
+ * a rejected call). Mirrors the shape used by slack.ts's
491
+ * agent_session_stopped handler for the same API.
492
+ */
493
+ private async setSessionStatus(
494
+ sessionStatus: "active" | "processing",
495
+ ): Promise<void> {
496
+ if (this.externallyStopped) return;
497
+ try {
498
+ await this.client.agents.sessions.setStatus({
499
+ channel_id: this.channel,
500
+ thread_ts: this.threadTs,
501
+ status: sessionStatus,
502
+ });
503
+ } catch (err) {
504
+ console.warn(
505
+ "[slack-progress] setStatus failed:",
506
+ err instanceof Error ? err.message : String(err),
507
+ );
508
+ }
509
+ }
510
+ }