@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.
- 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 +50 -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,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
|
+
});
|