@app-vitals/agent-runtime 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +40 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- 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
|
+
}
|