@app-vitals/agent-runtime 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +38 -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,3979 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for agent/src/runtime/slack.ts
|
|
3
|
+
*
|
|
4
|
+
* Strategy: inject all dependencies via createSlackApp's params. No mock.module()
|
|
5
|
+
* needed anywhere.
|
|
6
|
+
*
|
|
7
|
+
* - appFactory: MockApp class that captures constructor args + handlers
|
|
8
|
+
* - runner: mock function
|
|
9
|
+
* - formatter: mock function
|
|
10
|
+
* - getThreadKey: real threadKey (pure function, no deps)
|
|
11
|
+
* - slackConfig: plain object with test credentials
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
15
|
+
import { unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import type { ErrorCapturingClient } from "@app-vitals/agent-lib/sentry";
|
|
19
|
+
import {
|
|
20
|
+
type AgentSlackMembershipRef,
|
|
21
|
+
createAgentSlackMembershipRef,
|
|
22
|
+
} from "./agent-slack-membership-ref.ts";
|
|
23
|
+
import {
|
|
24
|
+
type ChatTokenReporter,
|
|
25
|
+
NoopChatTokenReporter,
|
|
26
|
+
} from "./chat-token-reporter.ts";
|
|
27
|
+
import type { ModelUsage, ProgressCallback, TokenUsage } from "./claude.ts";
|
|
28
|
+
import {
|
|
29
|
+
ClaudeRunError,
|
|
30
|
+
ClaudeTimeoutError,
|
|
31
|
+
createRunClaude,
|
|
32
|
+
} from "./claude.ts";
|
|
33
|
+
import type { markdownToBlocks } from "./format.ts";
|
|
34
|
+
import { threadKey } from "./sessions.ts";
|
|
35
|
+
import {
|
|
36
|
+
createSlackApp as _createSlackApp,
|
|
37
|
+
dispatchMarkers,
|
|
38
|
+
downloadFile,
|
|
39
|
+
formatRunErrorForSlack,
|
|
40
|
+
type SlackFile,
|
|
41
|
+
type SynthesizeSpeechFn,
|
|
42
|
+
type TranscribeAudioFn,
|
|
43
|
+
} from "./slack.ts";
|
|
44
|
+
import type { VoiceConfig } from "./voice.ts";
|
|
45
|
+
|
|
46
|
+
// ─── Mock resolveUserFn ───────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const mockResolveUserFn = mock(
|
|
49
|
+
async (_userId: string, _client: unknown): Promise<string> => "Dan",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// ─── Mock runner + formatter ──────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const mockUsage: TokenUsage = {
|
|
55
|
+
input_tokens: 100,
|
|
56
|
+
output_tokens: 50,
|
|
57
|
+
cache_read_input_tokens: 20,
|
|
58
|
+
cache_creation_input_tokens: 10,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const mockRunClaude = mock(
|
|
62
|
+
async (
|
|
63
|
+
_msg: string,
|
|
64
|
+
_key?: string,
|
|
65
|
+
_onProgress?: ProgressCallback,
|
|
66
|
+
): Promise<{
|
|
67
|
+
result: string;
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
usage?: TokenUsage;
|
|
70
|
+
totalCostUsd?: number;
|
|
71
|
+
modelUsage?: ModelUsage;
|
|
72
|
+
streamIncomplete?: boolean;
|
|
73
|
+
}> => ({
|
|
74
|
+
result: "Claude response text",
|
|
75
|
+
sessionId: "sess-xyz",
|
|
76
|
+
usage: mockUsage,
|
|
77
|
+
totalCostUsd: undefined,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const mockMarkdownToSlack = mock(
|
|
82
|
+
(text: string): string => `[formatted] ${text}`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// ─── Test slack config ────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const mockSlackConfig = {
|
|
88
|
+
botToken: "xoxb-test-token",
|
|
89
|
+
appToken: "xapp-test-token",
|
|
90
|
+
signingSecret: "test-secret",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ─── MockApp — captures constructor args and registered handlers ──────────────
|
|
94
|
+
|
|
95
|
+
type HandlerFn = (...args: unknown[]) => Promise<void>;
|
|
96
|
+
|
|
97
|
+
let capturedConstructorArgs: Record<string, unknown> | null = null;
|
|
98
|
+
let capturedMessageHandler: HandlerFn | null = null;
|
|
99
|
+
let capturedMentionHandler: HandlerFn | null = null;
|
|
100
|
+
let capturedReactionAddedHandler: HandlerFn | null = null;
|
|
101
|
+
|
|
102
|
+
// STS-2.1: createSlackApp() resolves the bot's own team ID once via
|
|
103
|
+
// app.client.auth.test() at construction — MockApp exposes the same shape
|
|
104
|
+
// Bolt's real App does (a `.client` WebClient) so that resolution path is
|
|
105
|
+
// exercised the same way it is in production, rather than always hitting
|
|
106
|
+
// the "no auth.test at all" branch. Resolves to a stable team_id by
|
|
107
|
+
// default; individual tests can reassign `.mockResolvedValueOnce`/
|
|
108
|
+
// `.mockRejectedValueOnce` on `mockAuthTest` to exercise the failure path.
|
|
109
|
+
const mockAuthTest = mock(async (_args?: unknown) => ({ team_id: "T-TEAM" }));
|
|
110
|
+
|
|
111
|
+
class MockApp {
|
|
112
|
+
client = { auth: { test: mockAuthTest } };
|
|
113
|
+
|
|
114
|
+
constructor(args: Record<string, unknown>) {
|
|
115
|
+
capturedConstructorArgs = args;
|
|
116
|
+
capturedMessageHandler = null;
|
|
117
|
+
capturedMentionHandler = null;
|
|
118
|
+
capturedReactionAddedHandler = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
message(handler: HandlerFn) {
|
|
122
|
+
capturedMessageHandler = handler;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
event(type: string, handler: HandlerFn) {
|
|
126
|
+
if (type === "app_mention") capturedMentionHandler = handler;
|
|
127
|
+
if (type === "reaction_added") capturedReactionAddedHandler = handler;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Fake sentry client — captures errors instead of asserting on a tracker ──
|
|
132
|
+
|
|
133
|
+
let capturedErrors: unknown[] = [];
|
|
134
|
+
let capturedMessages: string[] = [];
|
|
135
|
+
const fakeSentryClient: ErrorCapturingClient = {
|
|
136
|
+
captureException: (err: unknown) => {
|
|
137
|
+
capturedErrors.push(err);
|
|
138
|
+
},
|
|
139
|
+
captureMessage: (message: string) => {
|
|
140
|
+
capturedMessages.push(message);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Wrap with test deps so all tests call createSlackApp() with no args
|
|
145
|
+
function createSlackApp(
|
|
146
|
+
overrides: {
|
|
147
|
+
fileDownloaderFn?: (
|
|
148
|
+
file: SlackFile,
|
|
149
|
+
botToken: string,
|
|
150
|
+
) => Promise<string | null>;
|
|
151
|
+
voiceConfig?: VoiceConfig;
|
|
152
|
+
transcribeAudioFn?: TranscribeAudioFn;
|
|
153
|
+
synthesizeSpeechFn?: SynthesizeSpeechFn;
|
|
154
|
+
resolveUserFn?: (userId: string, client: unknown) => Promise<string>;
|
|
155
|
+
botUserId?: string | undefined;
|
|
156
|
+
conversationsRepliesFn?: (
|
|
157
|
+
client: unknown,
|
|
158
|
+
channel: string,
|
|
159
|
+
ts: string,
|
|
160
|
+
) => Promise<{
|
|
161
|
+
messages?: { user?: string; text?: string; ts?: string }[];
|
|
162
|
+
}>;
|
|
163
|
+
getSessionFn?: (key: string) => Promise<string | undefined>;
|
|
164
|
+
blocksConverter?: typeof markdownToBlocks;
|
|
165
|
+
chatTokenReporter?: ChatTokenReporter;
|
|
166
|
+
sentryClient?: ErrorCapturingClient;
|
|
167
|
+
resolveUserEmailFn?: (
|
|
168
|
+
userId: string,
|
|
169
|
+
client: unknown,
|
|
170
|
+
) => Promise<string | undefined>;
|
|
171
|
+
membershipRef?: AgentSlackMembershipRef;
|
|
172
|
+
} = {},
|
|
173
|
+
) {
|
|
174
|
+
capturedErrors = [];
|
|
175
|
+
capturedMessages = [];
|
|
176
|
+
return _createSlackApp(
|
|
177
|
+
mockRunClaude,
|
|
178
|
+
mockMarkdownToSlack,
|
|
179
|
+
threadKey,
|
|
180
|
+
// biome-ignore lint/suspicious/noExplicitAny: mock factory for tests
|
|
181
|
+
(cfg) => new MockApp(cfg as Record<string, unknown>) as any,
|
|
182
|
+
mockSlackConfig,
|
|
183
|
+
overrides.sentryClient ?? fakeSentryClient,
|
|
184
|
+
overrides.fileDownloaderFn ?? (async () => null),
|
|
185
|
+
overrides.voiceConfig ?? {},
|
|
186
|
+
overrides.transcribeAudioFn ?? (async () => null),
|
|
187
|
+
overrides.synthesizeSpeechFn ?? (async () => null),
|
|
188
|
+
overrides.resolveUserFn ?? mockResolveUserFn,
|
|
189
|
+
"botUserId" in overrides ? overrides.botUserId : "UBOT123",
|
|
190
|
+
overrides.conversationsRepliesFn ?? (async () => ({ messages: [] })),
|
|
191
|
+
overrides.getSessionFn ?? (async () => undefined),
|
|
192
|
+
overrides.blocksConverter,
|
|
193
|
+
overrides.chatTokenReporter ?? new NoopChatTokenReporter(),
|
|
194
|
+
overrides.resolveUserEmailFn ?? (async () => undefined),
|
|
195
|
+
// A fresh, unsynced ref per call — never the process-wide
|
|
196
|
+
// agentSlackMembershipRef singleton, which other test files (e.g.
|
|
197
|
+
// agent-slack-membership-ref.unit.test.ts) mutate directly. Falling
|
|
198
|
+
// through to that shared singleton would make this suite's outcome
|
|
199
|
+
// depend on bun test's file execution order (see CLAUDE.md's test
|
|
200
|
+
// isolation hard rule).
|
|
201
|
+
overrides.membershipRef ?? createAgentSlackMembershipRef(),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Mock client helpers ──────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
function makeMockClient() {
|
|
208
|
+
return {
|
|
209
|
+
agents: {
|
|
210
|
+
sessions: {
|
|
211
|
+
setStatus: mock(async (_args: unknown) => {}),
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
reactions: {
|
|
215
|
+
add: mock(async (_args: unknown) => {}),
|
|
216
|
+
remove: mock(async (_args: unknown) => {}),
|
|
217
|
+
},
|
|
218
|
+
files: {
|
|
219
|
+
uploadV2: mock(async (_args: unknown) => {}),
|
|
220
|
+
},
|
|
221
|
+
conversations: {
|
|
222
|
+
open: mock(async (_args: unknown) => ({ channel: { id: "DM_CHAN_1" } })),
|
|
223
|
+
},
|
|
224
|
+
chat: {
|
|
225
|
+
postMessage: mock(async (_args: unknown) => ({ ts: "resp.ts.1" })),
|
|
226
|
+
getPermalink: mock(async (_args: unknown) => ({
|
|
227
|
+
permalink: "https://slack.com/archives/C1/p1234",
|
|
228
|
+
})),
|
|
229
|
+
startStream: mock(async (_args: unknown) => ({ ts: "stream.ts.1" })),
|
|
230
|
+
appendStream: mock(async (_args: unknown) => {}),
|
|
231
|
+
stopStream: mock(async (_args: unknown) => {}),
|
|
232
|
+
},
|
|
233
|
+
users: {
|
|
234
|
+
info: mock(async (_args: unknown) => ({
|
|
235
|
+
user: {
|
|
236
|
+
profile: { display_name: "Test User", real_name: "Test User Real" },
|
|
237
|
+
name: "testuser",
|
|
238
|
+
},
|
|
239
|
+
})),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function makeSay(replyTs = "reply.ts.1") {
|
|
245
|
+
return mock(async (_args: unknown) => ({ ts: replyTs }));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── createSlackApp tests ─────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
describe("createSlackApp", () => {
|
|
251
|
+
test("returns a MockApp instance", () => {
|
|
252
|
+
const app = createSlackApp();
|
|
253
|
+
expect(app).toBeInstanceOf(MockApp);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("constructs App with botToken from config", () => {
|
|
257
|
+
createSlackApp();
|
|
258
|
+
expect(capturedConstructorArgs?.token).toBe("xoxb-test-token");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("constructs App with appToken from config", () => {
|
|
262
|
+
createSlackApp();
|
|
263
|
+
expect(capturedConstructorArgs?.appToken).toBe("xapp-test-token");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("constructs App with signingSecret from config", () => {
|
|
267
|
+
createSlackApp();
|
|
268
|
+
expect(capturedConstructorArgs?.signingSecret).toBe("test-secret");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("enables socketMode", () => {
|
|
272
|
+
createSlackApp();
|
|
273
|
+
expect(capturedConstructorArgs?.socketMode).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("registers a message handler", () => {
|
|
277
|
+
createSlackApp();
|
|
278
|
+
expect(capturedMessageHandler).toBeTypeOf("function");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("registers an app_mention event handler", () => {
|
|
282
|
+
createSlackApp();
|
|
283
|
+
expect(capturedMentionHandler).toBeTypeOf("function");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ─── message handler tests (DM routing) ──────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
describe("message handler — DM routing", () => {
|
|
290
|
+
beforeEach(() => {
|
|
291
|
+
mockRunClaude.mockClear();
|
|
292
|
+
mockMarkdownToSlack.mockClear();
|
|
293
|
+
createSlackApp();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
async function invokeDM(
|
|
297
|
+
overrides: Partial<{
|
|
298
|
+
subtype: string;
|
|
299
|
+
text: string;
|
|
300
|
+
channel: string;
|
|
301
|
+
ts: string;
|
|
302
|
+
thread_ts: string;
|
|
303
|
+
files: SlackFile[];
|
|
304
|
+
blocks: Array<{ type: string; elements?: unknown[] }>;
|
|
305
|
+
user: string;
|
|
306
|
+
}> = {},
|
|
307
|
+
) {
|
|
308
|
+
const client = makeMockClient();
|
|
309
|
+
const say = makeSay();
|
|
310
|
+
const message = {
|
|
311
|
+
channel: "D123",
|
|
312
|
+
ts: "111.222",
|
|
313
|
+
text: "Hello bot",
|
|
314
|
+
channel_type: "im",
|
|
315
|
+
...overrides,
|
|
316
|
+
};
|
|
317
|
+
await capturedMessageHandler?.({ message, say, client });
|
|
318
|
+
return { client, say };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
test("fires chatTokenReporter.recordSession with the session usage on a DM", async () => {
|
|
322
|
+
const recordSession = mock(
|
|
323
|
+
async (
|
|
324
|
+
_usage?: TokenUsage,
|
|
325
|
+
_totalCostUsd?: number,
|
|
326
|
+
_modelUsage?: ModelUsage,
|
|
327
|
+
) => {},
|
|
328
|
+
);
|
|
329
|
+
createSlackApp({ chatTokenReporter: { recordSession } });
|
|
330
|
+
|
|
331
|
+
await invokeDM({ text: "hello" });
|
|
332
|
+
|
|
333
|
+
expect(recordSession).toHaveBeenCalledTimes(1);
|
|
334
|
+
expect(recordSession).toHaveBeenCalledWith(mockUsage, undefined, undefined);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("returns early when message has a non-file subtype", async () => {
|
|
338
|
+
const { client, say } = await invokeDM({ subtype: "bot_message" });
|
|
339
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
340
|
+
expect(say).not.toHaveBeenCalled();
|
|
341
|
+
expect(client.reactions.add).not.toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("processes file_share messages", async () => {
|
|
345
|
+
const file: SlackFile = {
|
|
346
|
+
name: "test.txt",
|
|
347
|
+
mimetype: "text/plain",
|
|
348
|
+
size: 100,
|
|
349
|
+
url_private: "https://files.slack.com/test.txt",
|
|
350
|
+
};
|
|
351
|
+
createSlackApp({ fileDownloaderFn: async () => "/tmp/test.txt" });
|
|
352
|
+
const { client } = await invokeDM({
|
|
353
|
+
subtype: "file_share",
|
|
354
|
+
text: "",
|
|
355
|
+
files: [file],
|
|
356
|
+
});
|
|
357
|
+
expect(mockRunClaude).toHaveBeenCalled();
|
|
358
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("returns early when message has no text and no files", async () => {
|
|
362
|
+
const { client: _client, say } = await invokeDM({ text: undefined });
|
|
363
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
364
|
+
expect(say).not.toHaveBeenCalled();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("does not return early when message has empty text but rich_text blocks", async () => {
|
|
368
|
+
const { client } = await invokeDM({
|
|
369
|
+
text: "",
|
|
370
|
+
blocks: [
|
|
371
|
+
{
|
|
372
|
+
type: "rich_text",
|
|
373
|
+
elements: [
|
|
374
|
+
{
|
|
375
|
+
type: "rich_text_section",
|
|
376
|
+
elements: [{ type: "text", text: "Hello from rich text" }],
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
expect(mockRunClaude).toHaveBeenCalled();
|
|
383
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("converts rich_text blocks to markdown and passes to runClaude when text is absent", async () => {
|
|
387
|
+
await invokeDM({
|
|
388
|
+
text: "",
|
|
389
|
+
channel: "D123",
|
|
390
|
+
ts: "111.222",
|
|
391
|
+
blocks: [
|
|
392
|
+
{
|
|
393
|
+
type: "rich_text",
|
|
394
|
+
elements: [
|
|
395
|
+
{
|
|
396
|
+
type: "rich_text_section",
|
|
397
|
+
elements: [{ type: "text", text: "Hello from rich text" }],
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
});
|
|
403
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
404
|
+
"Hello from rich text",
|
|
405
|
+
"D123:111.222",
|
|
406
|
+
expect.any(Function),
|
|
407
|
+
{ SLACK_CHANNEL_ID: "D123", SLACK_THREAD_TS: "111.222" },
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("uses msg.text over rich_text blocks when both are present", async () => {
|
|
412
|
+
await invokeDM({
|
|
413
|
+
text: "Plain text wins",
|
|
414
|
+
channel: "D123",
|
|
415
|
+
ts: "111.222",
|
|
416
|
+
blocks: [
|
|
417
|
+
{
|
|
418
|
+
type: "rich_text",
|
|
419
|
+
elements: [
|
|
420
|
+
{
|
|
421
|
+
type: "rich_text_section",
|
|
422
|
+
elements: [{ type: "text", text: "Rich text should be ignored" }],
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
});
|
|
428
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
429
|
+
"Plain text wins",
|
|
430
|
+
"D123:111.222",
|
|
431
|
+
expect.any(Function),
|
|
432
|
+
{ SLACK_CHANNEL_ID: "D123", SLACK_THREAD_TS: "111.222" },
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("calls runClaude with message text and thread sessionKey", async () => {
|
|
437
|
+
await invokeDM({
|
|
438
|
+
text: "Do the thing",
|
|
439
|
+
channel: "D123",
|
|
440
|
+
ts: "111.222",
|
|
441
|
+
});
|
|
442
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
443
|
+
"Do the thing",
|
|
444
|
+
"D123:111.222",
|
|
445
|
+
expect.any(Function),
|
|
446
|
+
{ SLACK_CHANNEL_ID: "D123", SLACK_THREAD_TS: "111.222" },
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("uses thread_ts as sessionKey when available", async () => {
|
|
451
|
+
await invokeDM({ channel: "D456", ts: "1.1", thread_ts: "0.9" });
|
|
452
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
453
|
+
"Hello bot",
|
|
454
|
+
"D456:0.9",
|
|
455
|
+
expect.any(Function),
|
|
456
|
+
{ SLACK_CHANNEL_ID: "D456", SLACK_THREAD_TS: "0.9" },
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("formats result with markdownToSlack before posting via the say() fallback", async () => {
|
|
461
|
+
// The streaming path delivers `cleaned` unformatted via markdown_text —
|
|
462
|
+
// formatter(cleaned) is only used on the say()/blocksConverter fallback,
|
|
463
|
+
// so force that path by failing the stream delivery.
|
|
464
|
+
const client = makeMockClient();
|
|
465
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
466
|
+
const say = makeSay();
|
|
467
|
+
await capturedMessageHandler?.({
|
|
468
|
+
message: {
|
|
469
|
+
channel: "D123",
|
|
470
|
+
ts: "111.222",
|
|
471
|
+
text: "hi",
|
|
472
|
+
channel_type: "im",
|
|
473
|
+
},
|
|
474
|
+
say,
|
|
475
|
+
client,
|
|
476
|
+
});
|
|
477
|
+
expect(mockMarkdownToSlack).toHaveBeenCalledWith("Claude response text");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("opens the Thinking Steps stream using thread_ts when available", async () => {
|
|
481
|
+
const { client } = await invokeDM({ ts: "1.1", thread_ts: "0.9" });
|
|
482
|
+
expect(client.chat.startStream).toHaveBeenCalledWith(
|
|
483
|
+
expect.objectContaining({ thread_ts: "0.9" }),
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("a reply never posts via client.chat.postMessage — only the Thinking Steps stream or say()", async () => {
|
|
488
|
+
// SlackProgress never calls chat.postMessage. By default (mock client
|
|
489
|
+
// has a working chat namespace) the real reply is delivered via the
|
|
490
|
+
// stream's markdown_text chunk and say() is not used.
|
|
491
|
+
const { client, say } = await invokeDM({ channel: "D123", ts: "111.222" });
|
|
492
|
+
expect(say).not.toHaveBeenCalled();
|
|
493
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("the message handler's SlackProgress opens a Thinking Steps stream (STS-1.1)", async () => {
|
|
497
|
+
createSlackApp();
|
|
498
|
+
const { client } = await invokeDM({ channel: "D123", ts: "111.222" });
|
|
499
|
+
expect(client.chat.startStream).toHaveBeenCalledWith({
|
|
500
|
+
channel: "D123",
|
|
501
|
+
thread_ts: "111.222",
|
|
502
|
+
task_display_mode: "timeline",
|
|
503
|
+
});
|
|
504
|
+
expect(client.chat.stopStream).toHaveBeenCalled();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// STS-2.1 AC #2: a DM omits recipient_team_id/recipient_user_id entirely,
|
|
508
|
+
// even though MockApp's client.auth.test() resolves a real team_id and the
|
|
509
|
+
// message carries a `user` — they're not required (and may not even be
|
|
510
|
+
// meaningful) for a DM, which already fully identifies the recipient.
|
|
511
|
+
test("omits recipient_team_id and recipient_user_id on chat.startStream for a DM (STS-2.1 AC #2)", async () => {
|
|
512
|
+
createSlackApp();
|
|
513
|
+
const { client } = await invokeDM({
|
|
514
|
+
channel: "D123",
|
|
515
|
+
ts: "111.222",
|
|
516
|
+
user: "U-SENDER",
|
|
517
|
+
});
|
|
518
|
+
const call = client.chat.startStream.mock.calls[0]?.[0] as Record<
|
|
519
|
+
string,
|
|
520
|
+
unknown
|
|
521
|
+
>;
|
|
522
|
+
expect(call).not.toHaveProperty("recipient_team_id");
|
|
523
|
+
expect(call).not.toHaveProperty("recipient_user_id");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ─── STS2-1.1 regression: queued-message burst ──────────────────────────
|
|
527
|
+
//
|
|
528
|
+
// Every message gets its own independent stream lifecycle regardless of
|
|
529
|
+
// ThreadStatusTracker's refcount (AC #5) — the refcount only informs the
|
|
530
|
+
// seed card's title: "Thinking…" for the first message on a thread,
|
|
531
|
+
// "Queued…" for every message that arrives while an earlier one on the
|
|
532
|
+
// same thread is still in flight. Without the fix, enter()/exit() GATED
|
|
533
|
+
// whether start()/finish() were even called — the second+ message in a
|
|
534
|
+
// burst never opened its own stream at all.
|
|
535
|
+
test("a burst of queued messages on the same thread each get their own stream + correct Queued/Thinking seed labels (STS2-1.1 AC #5)", async () => {
|
|
536
|
+
createSlackApp();
|
|
537
|
+
|
|
538
|
+
// Two deferred runner calls so both messages are genuinely in flight at
|
|
539
|
+
// once — the second is dispatched before the first's runner() resolves.
|
|
540
|
+
let resolveFirst: (v: { result: string; sessionId?: string }) => void =
|
|
541
|
+
() => {};
|
|
542
|
+
let resolveSecond: (v: { result: string; sessionId?: string }) => void =
|
|
543
|
+
() => {};
|
|
544
|
+
mockRunClaude.mockImplementationOnce(
|
|
545
|
+
() =>
|
|
546
|
+
new Promise((resolve) => {
|
|
547
|
+
resolveFirst = resolve;
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
mockRunClaude.mockImplementationOnce(
|
|
551
|
+
() =>
|
|
552
|
+
new Promise((resolve) => {
|
|
553
|
+
resolveSecond = resolve;
|
|
554
|
+
}),
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
const client = makeMockClient();
|
|
558
|
+
const say1 = makeSay("reply.ts.1");
|
|
559
|
+
const say2 = makeSay("reply.ts.2");
|
|
560
|
+
// Same thread_ts on both — the tracker keys on
|
|
561
|
+
// getThreadKey(channel, thread_ts ?? ts), so a shared thread_ts is what
|
|
562
|
+
// makes this a genuine same-thread burst (distinct top-level ts values
|
|
563
|
+
// alone would produce distinct, unrelated session keys).
|
|
564
|
+
const message = {
|
|
565
|
+
channel: "D-BURST",
|
|
566
|
+
ts: "1.1",
|
|
567
|
+
thread_ts: "1.0",
|
|
568
|
+
text: "first",
|
|
569
|
+
channel_type: "im",
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// Fire both messages on the same thread without awaiting the first —
|
|
573
|
+
// start() for #2 runs while #1's runner() call is still pending.
|
|
574
|
+
const p1 = capturedMessageHandler?.({
|
|
575
|
+
message,
|
|
576
|
+
say: say1,
|
|
577
|
+
client,
|
|
578
|
+
});
|
|
579
|
+
const p2 = capturedMessageHandler?.({
|
|
580
|
+
message: { ...message, ts: "1.2", text: "second" },
|
|
581
|
+
say: say2,
|
|
582
|
+
client,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Both handlers should have opened their own stream by now (start() is
|
|
586
|
+
// awaited synchronously before the runner() call inside each handler).
|
|
587
|
+
// A real setTimeout tick (not just queued microtasks) is needed — the
|
|
588
|
+
// handler crosses several real awaits (startStream, buildPromptWithFiles,
|
|
589
|
+
// etc.) before reaching the runner() call below.
|
|
590
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
591
|
+
|
|
592
|
+
expect(client.chat.startStream).toHaveBeenCalledTimes(2);
|
|
593
|
+
|
|
594
|
+
const appendCalls = client.chat.appendStream.mock.calls.map(
|
|
595
|
+
(c) => c[0] as { chunks: Array<Record<string, unknown>> },
|
|
596
|
+
);
|
|
597
|
+
const seedTitles = appendCalls
|
|
598
|
+
.map((c) => c.chunks.find((ch) => ch.type === "task_update")?.title)
|
|
599
|
+
.filter((t): t is string => typeof t === "string");
|
|
600
|
+
|
|
601
|
+
expect(seedTitles).toContain("Thinking…");
|
|
602
|
+
expect(seedTitles).toContain("Queued…");
|
|
603
|
+
|
|
604
|
+
resolveFirst({ result: "first done", sessionId: "s1" });
|
|
605
|
+
resolveSecond({ result: "second done", sessionId: "s2" });
|
|
606
|
+
await p1;
|
|
607
|
+
await p2;
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// ─── STS2-1.1 regression: first-to-finish keeps session_status processing
|
|
611
|
+
//
|
|
612
|
+
// stopStream's session_status is per-thread, not per-message — the
|
|
613
|
+
// first-to-finish message of a burst must NOT dismiss the "Bodhi is
|
|
614
|
+
// working" indicator for the whole thread while a later message is still
|
|
615
|
+
// running (AC #5).
|
|
616
|
+
test("the first-to-finish message of a burst keeps session_status 'processing' while a later one is still in flight (STS2-1.1 AC #5)", async () => {
|
|
617
|
+
createSlackApp();
|
|
618
|
+
|
|
619
|
+
let resolveFirst: (v: { result: string; sessionId?: string }) => void =
|
|
620
|
+
() => {};
|
|
621
|
+
let resolveSecond: (v: { result: string; sessionId?: string }) => void =
|
|
622
|
+
() => {};
|
|
623
|
+
mockRunClaude.mockImplementationOnce(
|
|
624
|
+
() =>
|
|
625
|
+
new Promise((resolve) => {
|
|
626
|
+
resolveFirst = resolve;
|
|
627
|
+
}),
|
|
628
|
+
);
|
|
629
|
+
mockRunClaude.mockImplementationOnce(
|
|
630
|
+
() =>
|
|
631
|
+
new Promise((resolve) => {
|
|
632
|
+
resolveSecond = resolve;
|
|
633
|
+
}),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const client = makeMockClient();
|
|
637
|
+
const say1 = makeSay("reply.ts.1");
|
|
638
|
+
const say2 = makeSay("reply.ts.2");
|
|
639
|
+
// Same thread_ts on both — see the matching comment in the burst-seed
|
|
640
|
+
// test above.
|
|
641
|
+
const message = {
|
|
642
|
+
channel: "D-BURST2",
|
|
643
|
+
ts: "2.1",
|
|
644
|
+
thread_ts: "2.0",
|
|
645
|
+
text: "first",
|
|
646
|
+
channel_type: "im",
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const p1 = capturedMessageHandler?.({
|
|
650
|
+
message,
|
|
651
|
+
say: say1,
|
|
652
|
+
client,
|
|
653
|
+
});
|
|
654
|
+
const p2 = capturedMessageHandler?.({
|
|
655
|
+
message: { ...message, ts: "2.2", text: "second" },
|
|
656
|
+
say: say2,
|
|
657
|
+
client,
|
|
658
|
+
});
|
|
659
|
+
// A real setTimeout tick — see the matching comment in the burst-seed
|
|
660
|
+
// test above.
|
|
661
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
662
|
+
|
|
663
|
+
// First message finishes while the second is still running.
|
|
664
|
+
resolveFirst({ result: "first done", sessionId: "s1" });
|
|
665
|
+
await p1;
|
|
666
|
+
|
|
667
|
+
const stopCalls = client.chat.stopStream.mock.calls.map(
|
|
668
|
+
(c) => c[0] as { session_status?: string },
|
|
669
|
+
);
|
|
670
|
+
expect(stopCalls).toHaveLength(1);
|
|
671
|
+
expect(stopCalls[0]?.session_status).toBe("processing");
|
|
672
|
+
|
|
673
|
+
// Now the second (genuinely last) message finishes — closes "active".
|
|
674
|
+
resolveSecond({ result: "second done", sessionId: "s2" });
|
|
675
|
+
await p2;
|
|
676
|
+
|
|
677
|
+
const stopCallsAfter = client.chat.stopStream.mock.calls.map(
|
|
678
|
+
(c) => c[0] as { session_status?: string },
|
|
679
|
+
);
|
|
680
|
+
expect(stopCallsAfter).toHaveLength(2);
|
|
681
|
+
expect(stopCallsAfter[1]?.session_status).toBe("active");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// ─── STS2-1.1: chunk schema, setStatus skip, delivery ───────────────────
|
|
685
|
+
|
|
686
|
+
test("calls agents.sessions.setStatus eagerly at start and unconditionally at finish, alongside streaming (ISW-1.1)", async () => {
|
|
687
|
+
createSlackApp();
|
|
688
|
+
const { client } = await invokeDM({ channel: "D123", ts: "111.222" });
|
|
689
|
+
expect(client.agents.sessions.setStatus).toHaveBeenCalledWith({
|
|
690
|
+
channel_id: "D123",
|
|
691
|
+
thread_ts: "111.222",
|
|
692
|
+
status: "processing",
|
|
693
|
+
});
|
|
694
|
+
expect(client.agents.sessions.setStatus).toHaveBeenCalledWith({
|
|
695
|
+
channel_id: "D123",
|
|
696
|
+
thread_ts: "111.222",
|
|
697
|
+
status: "active",
|
|
698
|
+
});
|
|
699
|
+
// Independent of, not instead of, the stream's own close call.
|
|
700
|
+
expect(client.chat.stopStream).toHaveBeenCalledWith(
|
|
701
|
+
expect.objectContaining({ session_status: "active" }),
|
|
702
|
+
);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("every task_update chunk uses the type/id/title/status shape, never the old {type, text} shape (STS2-1.1 AC #1)", async () => {
|
|
706
|
+
createSlackApp();
|
|
707
|
+
const { client } = await invokeDM({ channel: "D123", ts: "111.222" });
|
|
708
|
+
const taskUpdateChunks = client.chat.appendStream.mock.calls
|
|
709
|
+
.flatMap(
|
|
710
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
711
|
+
)
|
|
712
|
+
.filter((chunk) => chunk.type === "task_update");
|
|
713
|
+
expect(taskUpdateChunks.length).toBeGreaterThan(0);
|
|
714
|
+
for (const chunk of taskUpdateChunks) {
|
|
715
|
+
expect(chunk).not.toHaveProperty("text");
|
|
716
|
+
expect(chunk.id).toBeDefined();
|
|
717
|
+
expect(["in_progress", "complete", "error"]).toContain(
|
|
718
|
+
chunk.status as string,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("reuses a single stable task_update id across the whole run (STS2-1.1 AC #1)", async () => {
|
|
724
|
+
createSlackApp();
|
|
725
|
+
const { client } = await invokeDM({ channel: "D123", ts: "111.222" });
|
|
726
|
+
const ids = client.chat.appendStream.mock.calls
|
|
727
|
+
.flatMap(
|
|
728
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
729
|
+
)
|
|
730
|
+
.filter((chunk) => chunk.type === "task_update")
|
|
731
|
+
.map((chunk) => chunk.id);
|
|
732
|
+
expect(new Set(ids).size).toBe(1);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("delivers the final reply as a markdown_text chunk via chat.appendStream, and skips say() (STS2-1.1 AC #2)", async () => {
|
|
736
|
+
createSlackApp();
|
|
737
|
+
const { client, say } = await invokeDM({ channel: "D123", ts: "111.222" });
|
|
738
|
+
|
|
739
|
+
const markdownChunks = client.chat.appendStream.mock.calls
|
|
740
|
+
.flatMap(
|
|
741
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
742
|
+
)
|
|
743
|
+
.filter((chunk) => chunk.type === "markdown_text");
|
|
744
|
+
expect(markdownChunks).toContainEqual({
|
|
745
|
+
type: "markdown_text",
|
|
746
|
+
text: "Claude response text",
|
|
747
|
+
});
|
|
748
|
+
// Delivered via the stream — say() is not used for the real reply.
|
|
749
|
+
expect(say).not.toHaveBeenCalled();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// STS2-3.1: a [silent] response must still close the stream's dangling
|
|
753
|
+
// in_progress card — with a distinct "Ack" title (not "Done", which would
|
|
754
|
+
// imply a real reply went out) and no markdown_text content.
|
|
755
|
+
test("closes the stream with an 'Ack' completion card and posts no content when Claude returns [silent] (STS2-3.1)", async () => {
|
|
756
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
757
|
+
result: "[silent]",
|
|
758
|
+
sessionId: "sess-silent",
|
|
759
|
+
});
|
|
760
|
+
createSlackApp();
|
|
761
|
+
const { client, say } = await invokeDM({ channel: "D123", ts: "111.222" });
|
|
762
|
+
|
|
763
|
+
expect(say).not.toHaveBeenCalled();
|
|
764
|
+
|
|
765
|
+
const chunks = client.chat.appendStream.mock.calls.flatMap(
|
|
766
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
767
|
+
);
|
|
768
|
+
const markdownChunks = chunks.filter((c) => c.type === "markdown_text");
|
|
769
|
+
expect(markdownChunks).toHaveLength(0);
|
|
770
|
+
|
|
771
|
+
const completeChunk = chunks.find(
|
|
772
|
+
(c) => c.type === "task_update" && c.status === "complete",
|
|
773
|
+
);
|
|
774
|
+
expect(completeChunk?.title).toBe("Ack");
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
test("falls back to say() when chat.appendStream rejects (delivery failure never drops the reply) (STS2-1.1 AC #2)", async () => {
|
|
778
|
+
const client = makeMockClient();
|
|
779
|
+
client.chat.appendStream.mockImplementation(async () => {
|
|
780
|
+
throw new Error("streaming_mode_mismatch");
|
|
781
|
+
});
|
|
782
|
+
const say = makeSay();
|
|
783
|
+
createSlackApp();
|
|
784
|
+
await capturedMessageHandler?.({
|
|
785
|
+
message: {
|
|
786
|
+
channel: "D-FALLBACK",
|
|
787
|
+
ts: "9.9",
|
|
788
|
+
text: "hi",
|
|
789
|
+
channel_type: "im",
|
|
790
|
+
},
|
|
791
|
+
say,
|
|
792
|
+
client,
|
|
793
|
+
});
|
|
794
|
+
expect(say).toHaveBeenCalledWith({
|
|
795
|
+
text: "[formatted] Claude response text",
|
|
796
|
+
thread_ts: "9.9",
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test("passes an onProgress function through to the runner as the third arg (AC #1)", async () => {
|
|
801
|
+
await invokeDM({ text: "hi", channel: "D123", ts: "111.222" });
|
|
802
|
+
const call = mockRunClaude.mock.calls.at(-1) as unknown[];
|
|
803
|
+
expect(call[2]).toBeTypeOf("function");
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("posts error message when runClaude throws", async () => {
|
|
807
|
+
mockRunClaude.mockRejectedValueOnce(new Error("spawn failed"));
|
|
808
|
+
const { say } = await invokeDM({ ts: "1.1" });
|
|
809
|
+
expect(say).toHaveBeenCalledWith({
|
|
810
|
+
text: expect.stringContaining("spawn failed"),
|
|
811
|
+
thread_ts: "1.1",
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("closes the Thinking Steps stream even when runClaude throws", async () => {
|
|
816
|
+
mockRunClaude.mockRejectedValueOnce(new Error("spawn failed"));
|
|
817
|
+
const { client } = await invokeDM({ channel: "D123", ts: "1.1" });
|
|
818
|
+
expect(client.chat.stopStream).toHaveBeenCalledWith(
|
|
819
|
+
expect.objectContaining({ session_status: "active" }),
|
|
820
|
+
);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test("does not throw when chat.stopStream fails", async () => {
|
|
824
|
+
const client = makeMockClient();
|
|
825
|
+
const say = makeSay();
|
|
826
|
+
client.chat.stopStream.mockRejectedValueOnce(new Error("api error"));
|
|
827
|
+
|
|
828
|
+
await expect(
|
|
829
|
+
capturedMessageHandler?.({
|
|
830
|
+
message: { channel: "D1", ts: "1.1", text: "hi", channel_type: "im" },
|
|
831
|
+
say,
|
|
832
|
+
client,
|
|
833
|
+
}),
|
|
834
|
+
).resolves.toBeUndefined();
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("does not call sentryClient.captureException on success", async () => {
|
|
838
|
+
await invokeDM({ channel: "D1", ts: "1.1", text: "hello" });
|
|
839
|
+
expect(capturedErrors.length).toBe(0);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("calls sentryClient.captureException when runClaude throws", async () => {
|
|
843
|
+
mockRunClaude.mockRejectedValueOnce(new Error("boom"));
|
|
844
|
+
await invokeDM({ channel: "D1", ts: "1.1" });
|
|
845
|
+
expect(capturedErrors.length).toBe(1);
|
|
846
|
+
expect((capturedErrors[0] as Error).message).toBe("boom");
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("SENTRY-46: a ceiling-reason ClaudeTimeoutError reports via captureMessage, not captureException", async () => {
|
|
850
|
+
mockRunClaude.mockRejectedValueOnce(
|
|
851
|
+
new ClaudeTimeoutError(3_600_000, "ceiling"),
|
|
852
|
+
);
|
|
853
|
+
await invokeDM({ channel: "D1", ts: "1.1" });
|
|
854
|
+
expect(capturedErrors.length).toBe(0);
|
|
855
|
+
expect(capturedMessages.length).toBe(1);
|
|
856
|
+
expect(capturedMessages[0]).toContain("3600s");
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test("an idle-reason ClaudeTimeoutError still reports via captureException", async () => {
|
|
860
|
+
mockRunClaude.mockRejectedValueOnce(
|
|
861
|
+
new ClaudeTimeoutError(1_500_000, "idle"),
|
|
862
|
+
);
|
|
863
|
+
await invokeDM({ channel: "D1", ts: "1.1" });
|
|
864
|
+
expect(capturedMessages.length).toBe(0);
|
|
865
|
+
expect(capturedErrors.length).toBe(1);
|
|
866
|
+
expect((capturedErrors[0] as ClaudeTimeoutError).reason).toBe("idle");
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test("marks the Thinking Steps card status:'error' when runClaude throws (STS2-4.1 AC #1)", async () => {
|
|
870
|
+
createSlackApp();
|
|
871
|
+
mockRunClaude.mockRejectedValueOnce(new Error("boom"));
|
|
872
|
+
const { client } = await invokeDM({ channel: "D123", ts: "111.222" });
|
|
873
|
+
|
|
874
|
+
const taskUpdateChunks = client.chat.appendStream.mock.calls
|
|
875
|
+
.flatMap(
|
|
876
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
877
|
+
)
|
|
878
|
+
.filter((chunk) => chunk.type === "task_update");
|
|
879
|
+
const errorChunks = taskUpdateChunks.filter(
|
|
880
|
+
(chunk) => chunk.status === "error",
|
|
881
|
+
);
|
|
882
|
+
expect(errorChunks.length).toBeGreaterThan(0);
|
|
883
|
+
expect(errorChunks[0]?.id).toBeDefined();
|
|
884
|
+
|
|
885
|
+
// finish() still closes the stream, and does not send a redundant
|
|
886
|
+
// status:"complete" card after the error card.
|
|
887
|
+
expect(client.chat.stopStream).toHaveBeenCalled();
|
|
888
|
+
const completeChunks = taskUpdateChunks.filter(
|
|
889
|
+
(chunk) => chunk.status === "complete",
|
|
890
|
+
);
|
|
891
|
+
expect(completeChunks.length).toBe(0);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("marks the card status:'error' even when the catch block's say() itself throws (STS2-4.1 AC #1 — #3064 review)", async () => {
|
|
895
|
+
// Both the run AND the error-notification say() fail. reportError() must
|
|
896
|
+
// still fire (it runs before say() in the catch block), so finish() sees
|
|
897
|
+
// completed=true and never sends a "Done" card for a run that both errored
|
|
898
|
+
// and failed to notify the user.
|
|
899
|
+
const client = makeMockClient();
|
|
900
|
+
const say = mock(async (_args: unknown) => {
|
|
901
|
+
throw new Error("invalid_auth");
|
|
902
|
+
});
|
|
903
|
+
createSlackApp();
|
|
904
|
+
mockRunClaude.mockRejectedValueOnce(new Error("boom"));
|
|
905
|
+
|
|
906
|
+
await capturedMessageHandler?.({
|
|
907
|
+
message: {
|
|
908
|
+
channel: "D123",
|
|
909
|
+
ts: "111.222",
|
|
910
|
+
text: "Hello bot",
|
|
911
|
+
channel_type: "im",
|
|
912
|
+
},
|
|
913
|
+
say,
|
|
914
|
+
client,
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
const taskUpdateChunks = client.chat.appendStream.mock.calls
|
|
918
|
+
.flatMap(
|
|
919
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
920
|
+
)
|
|
921
|
+
.filter((chunk) => chunk.type === "task_update");
|
|
922
|
+
const errorChunks = taskUpdateChunks.filter(
|
|
923
|
+
(chunk) => chunk.status === "error",
|
|
924
|
+
);
|
|
925
|
+
expect(errorChunks.length).toBeGreaterThan(0);
|
|
926
|
+
|
|
927
|
+
// No "Done" card despite the failed notification, and the stream still
|
|
928
|
+
// closes.
|
|
929
|
+
const completeChunks = taskUpdateChunks.filter(
|
|
930
|
+
(chunk) => chunk.status === "complete",
|
|
931
|
+
);
|
|
932
|
+
expect(completeChunks.length).toBe(0);
|
|
933
|
+
expect(client.chat.stopStream).toHaveBeenCalled();
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("streamIncomplete:true is treated as an error, not a silent empty success (CSU-1.1 regression)", async () => {
|
|
937
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
938
|
+
result: "",
|
|
939
|
+
streamIncomplete: true,
|
|
940
|
+
});
|
|
941
|
+
const { say } = await invokeDM({ channel: "D1", ts: "1.1" });
|
|
942
|
+
|
|
943
|
+
expect(capturedErrors.length).toBe(1);
|
|
944
|
+
expect((capturedErrors[0] as Error).message).toContain(
|
|
945
|
+
"stream ended without a terminal result event",
|
|
946
|
+
);
|
|
947
|
+
// An error reply is posted — a truncated stream must not look like a
|
|
948
|
+
// normal empty/markers-only response with no Slack post at all.
|
|
949
|
+
expect(say).toHaveBeenCalledTimes(1);
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// ─── message handler tests — thread routing ───────────────────────────────────
|
|
954
|
+
|
|
955
|
+
describe("message handler — channel thread routing", () => {
|
|
956
|
+
beforeEach(() => {
|
|
957
|
+
mockRunClaude.mockClear();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
async function invokeChannelMessage(
|
|
961
|
+
overrides: Partial<{
|
|
962
|
+
text: string;
|
|
963
|
+
channel: string;
|
|
964
|
+
ts: string;
|
|
965
|
+
thread_ts: string;
|
|
966
|
+
channel_type: string;
|
|
967
|
+
}> = {},
|
|
968
|
+
appOverrides: Parameters<typeof createSlackApp>[0] = {},
|
|
969
|
+
) {
|
|
970
|
+
createSlackApp(appOverrides);
|
|
971
|
+
const client = makeMockClient();
|
|
972
|
+
const say = makeSay();
|
|
973
|
+
const message = {
|
|
974
|
+
channel: "C123",
|
|
975
|
+
ts: "111.222",
|
|
976
|
+
text: "Followup message",
|
|
977
|
+
channel_type: "channel",
|
|
978
|
+
...overrides,
|
|
979
|
+
};
|
|
980
|
+
await capturedMessageHandler?.({ message, say, client });
|
|
981
|
+
return { client, say };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
test("ignores channel message with no thread_ts", async () => {
|
|
985
|
+
const { say } = await invokeChannelMessage({ thread_ts: undefined });
|
|
986
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
987
|
+
expect(say).not.toHaveBeenCalled();
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
test("ignores channel thread message when no session exists", async () => {
|
|
991
|
+
const { say } = await invokeChannelMessage({ thread_ts: "1.0" });
|
|
992
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
993
|
+
expect(say).not.toHaveBeenCalled();
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
test("routes channel thread message when session exists", async () => {
|
|
997
|
+
createSlackApp({ getSessionFn: mock(async () => "sess-abc") });
|
|
998
|
+
const client = makeMockClient();
|
|
999
|
+
const say = makeSay();
|
|
1000
|
+
const message = {
|
|
1001
|
+
channel: "C123",
|
|
1002
|
+
ts: "1.1",
|
|
1003
|
+
text: "Followup message",
|
|
1004
|
+
channel_type: "channel",
|
|
1005
|
+
thread_ts: "1.0",
|
|
1006
|
+
};
|
|
1007
|
+
await capturedMessageHandler?.({ message, say, client });
|
|
1008
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1009
|
+
"[Thread message — respond normally, or use [silent] if no response is needed]\nFollowup message",
|
|
1010
|
+
"C123:1.0",
|
|
1011
|
+
expect.any(Function),
|
|
1012
|
+
{ SLACK_CHANNEL_ID: "C123", SLACK_THREAD_TS: "1.0" },
|
|
1013
|
+
);
|
|
1014
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// STS-2.1 AC #3: when chat.startStream fails outright (e.g. the real
|
|
1018
|
+
// missing_recipient_team_id error this task fixes, or any other cause),
|
|
1019
|
+
// finish() must fall back to agents.sessions.setStatus directly rather
|
|
1020
|
+
// than only attempting a doomed chat.stopStream call — so the "is
|
|
1021
|
+
// working" indicator can still be cleared.
|
|
1022
|
+
test("falls back to agents.sessions.setStatus when chat.startStream fails outright (AC #3)", async () => {
|
|
1023
|
+
createSlackApp({ getSessionFn: mock(async () => "sess-abc") });
|
|
1024
|
+
const client = makeMockClient();
|
|
1025
|
+
client.chat.startStream.mockRejectedValueOnce(
|
|
1026
|
+
new Error("missing_recipient_team_id"),
|
|
1027
|
+
);
|
|
1028
|
+
const say = makeSay();
|
|
1029
|
+
const message = {
|
|
1030
|
+
channel: "C123",
|
|
1031
|
+
ts: "1.1",
|
|
1032
|
+
text: "Followup message",
|
|
1033
|
+
channel_type: "channel",
|
|
1034
|
+
thread_ts: "1.0",
|
|
1035
|
+
};
|
|
1036
|
+
await capturedMessageHandler?.({ message, say, client });
|
|
1037
|
+
|
|
1038
|
+
expect(client.chat.stopStream).not.toHaveBeenCalled();
|
|
1039
|
+
expect(client.agents.sessions.setStatus).toHaveBeenCalledWith({
|
|
1040
|
+
channel_id: "C123",
|
|
1041
|
+
thread_ts: "1.0",
|
|
1042
|
+
status: "active",
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test("routes when session exists (session-based routing)", async () => {
|
|
1047
|
+
// Routing is exclusively via getSessionFn — no activeThreads lookup
|
|
1048
|
+
createSlackApp({
|
|
1049
|
+
getSessionFn: mock(async () => "sess-xyz"),
|
|
1050
|
+
});
|
|
1051
|
+
const client = makeMockClient();
|
|
1052
|
+
const say = makeSay();
|
|
1053
|
+
const message = {
|
|
1054
|
+
channel: "C123",
|
|
1055
|
+
ts: "1.1",
|
|
1056
|
+
text: "Followup message",
|
|
1057
|
+
channel_type: "channel",
|
|
1058
|
+
thread_ts: "1.0",
|
|
1059
|
+
};
|
|
1060
|
+
await capturedMessageHandler?.({ message, say, client });
|
|
1061
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1062
|
+
"[Thread message — respond normally, or use [silent] if no response is needed]\nFollowup message",
|
|
1063
|
+
"C123:1.0",
|
|
1064
|
+
expect.any(Function),
|
|
1065
|
+
{ SLACK_CHANNEL_ID: "C123", SLACK_THREAD_TS: "1.0" },
|
|
1066
|
+
);
|
|
1067
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test("routes cron-started thread on first human reply (session exists, no @mention)", async () => {
|
|
1071
|
+
// Cron posts to a channel thread → onSession sets session → human replies → should route
|
|
1072
|
+
// Simulated by: getSessionFn returns the session the cron registered
|
|
1073
|
+
createSlackApp({ getSessionFn: mock(async () => "sess-cron-001") });
|
|
1074
|
+
const client = makeMockClient();
|
|
1075
|
+
const say = makeSay();
|
|
1076
|
+
const message = {
|
|
1077
|
+
channel: "C-CRON",
|
|
1078
|
+
ts: "200.1",
|
|
1079
|
+
text: "Good work bot",
|
|
1080
|
+
channel_type: "channel",
|
|
1081
|
+
thread_ts: "200.0", // thread started by cron
|
|
1082
|
+
};
|
|
1083
|
+
await capturedMessageHandler?.({ message, say, client });
|
|
1084
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1085
|
+
"[Thread message — respond normally, or use [silent] if no response is needed]\nGood work bot",
|
|
1086
|
+
"C-CRON:200.0",
|
|
1087
|
+
expect.any(Function),
|
|
1088
|
+
{ SLACK_CHANNEL_ID: "C-CRON", SLACK_THREAD_TS: "200.0" },
|
|
1089
|
+
);
|
|
1090
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test("does NOT prepend thread hint for DMs", async () => {
|
|
1094
|
+
createSlackApp();
|
|
1095
|
+
const client = makeMockClient();
|
|
1096
|
+
const say = makeSay();
|
|
1097
|
+
const message = {
|
|
1098
|
+
channel: "D123",
|
|
1099
|
+
ts: "1.1",
|
|
1100
|
+
text: "Hello bot",
|
|
1101
|
+
channel_type: "im",
|
|
1102
|
+
user: "U123",
|
|
1103
|
+
};
|
|
1104
|
+
await capturedMessageHandler?.({ message, say, client });
|
|
1105
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
1106
|
+
expect(prompt).not.toContain("[Thread message");
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test("ignores channel message with text-only whitespace", async () => {
|
|
1110
|
+
const { say } = await invokeChannelMessage(
|
|
1111
|
+
{ text: " ", thread_ts: "1.0" },
|
|
1112
|
+
{ getSessionFn: async () => "sess-abc" },
|
|
1113
|
+
);
|
|
1114
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
1115
|
+
expect(say).not.toHaveBeenCalled();
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
test("routes with session, ignores without", async () => {
|
|
1119
|
+
// With session: routes
|
|
1120
|
+
mockRunClaude.mockClear();
|
|
1121
|
+
createSlackApp({ getSessionFn: async () => "sess-abc" });
|
|
1122
|
+
const client1 = makeMockClient();
|
|
1123
|
+
const say1 = makeSay();
|
|
1124
|
+
await capturedMessageHandler?.({
|
|
1125
|
+
message: {
|
|
1126
|
+
channel: "C123",
|
|
1127
|
+
ts: "1.1",
|
|
1128
|
+
thread_ts: "1.0",
|
|
1129
|
+
text: "hello",
|
|
1130
|
+
channel_type: "channel",
|
|
1131
|
+
},
|
|
1132
|
+
say: say1,
|
|
1133
|
+
client: client1,
|
|
1134
|
+
});
|
|
1135
|
+
expect(mockRunClaude).toHaveBeenCalledTimes(1);
|
|
1136
|
+
|
|
1137
|
+
// Without session: ignores
|
|
1138
|
+
mockRunClaude.mockClear();
|
|
1139
|
+
createSlackApp({ getSessionFn: async () => undefined });
|
|
1140
|
+
const client2 = makeMockClient();
|
|
1141
|
+
const say2 = makeSay();
|
|
1142
|
+
await capturedMessageHandler?.({
|
|
1143
|
+
message: {
|
|
1144
|
+
channel: "C123",
|
|
1145
|
+
ts: "1.1",
|
|
1146
|
+
thread_ts: "1.0",
|
|
1147
|
+
text: "hello",
|
|
1148
|
+
channel_type: "channel",
|
|
1149
|
+
},
|
|
1150
|
+
say: say2,
|
|
1151
|
+
client: client2,
|
|
1152
|
+
});
|
|
1153
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
test("prepends thread hint for channel thread messages", async () => {
|
|
1157
|
+
createSlackApp({ getSessionFn: async () => "sess-abc" });
|
|
1158
|
+
const client = makeMockClient();
|
|
1159
|
+
const say = makeSay();
|
|
1160
|
+
await capturedMessageHandler?.({
|
|
1161
|
+
message: {
|
|
1162
|
+
channel: "C123",
|
|
1163
|
+
ts: "1.1",
|
|
1164
|
+
thread_ts: "1.0",
|
|
1165
|
+
text: "Followup message",
|
|
1166
|
+
channel_type: "channel",
|
|
1167
|
+
},
|
|
1168
|
+
say,
|
|
1169
|
+
client,
|
|
1170
|
+
});
|
|
1171
|
+
expect(mockRunClaude).toHaveBeenCalledTimes(1);
|
|
1172
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
1173
|
+
expect(prompt).toMatch(
|
|
1174
|
+
/^\[Thread message — respond normally, or use \[silent\] if no response is needed\]\n/,
|
|
1175
|
+
);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
test("does not prepend thread hint for DM messages", async () => {
|
|
1179
|
+
createSlackApp();
|
|
1180
|
+
const client = makeMockClient();
|
|
1181
|
+
const say = makeSay();
|
|
1182
|
+
await capturedMessageHandler?.({
|
|
1183
|
+
message: {
|
|
1184
|
+
channel: "D1",
|
|
1185
|
+
ts: "1.1",
|
|
1186
|
+
text: "Direct message",
|
|
1187
|
+
channel_type: "im",
|
|
1188
|
+
},
|
|
1189
|
+
say,
|
|
1190
|
+
client,
|
|
1191
|
+
});
|
|
1192
|
+
expect(mockRunClaude).toHaveBeenCalledTimes(1);
|
|
1193
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
1194
|
+
expect(prompt).not.toContain("[Thread message");
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
test("channel thread: [silent] suppresses say()", async () => {
|
|
1198
|
+
mockRunClaude.mockClear();
|
|
1199
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
1200
|
+
result: "text [silent]",
|
|
1201
|
+
sessionId: "s1",
|
|
1202
|
+
});
|
|
1203
|
+
createSlackApp({ getSessionFn: mock(async () => "sess-xyz") });
|
|
1204
|
+
const client = makeMockClient();
|
|
1205
|
+
const say = makeSay();
|
|
1206
|
+
await capturedMessageHandler?.({
|
|
1207
|
+
message: {
|
|
1208
|
+
channel: "C123",
|
|
1209
|
+
ts: "2.0",
|
|
1210
|
+
thread_ts: "1.0",
|
|
1211
|
+
text: "hi",
|
|
1212
|
+
channel_type: "channel",
|
|
1213
|
+
},
|
|
1214
|
+
say,
|
|
1215
|
+
client,
|
|
1216
|
+
});
|
|
1217
|
+
expect(say).not.toHaveBeenCalled();
|
|
1218
|
+
});
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
// ─── message handler tests — file handling ────────────────────────────────────
|
|
1222
|
+
|
|
1223
|
+
describe("message handler — file handling", () => {
|
|
1224
|
+
beforeEach(() => {
|
|
1225
|
+
mockRunClaude.mockClear();
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
test("injects file path into prompt when file downloads successfully", async () => {
|
|
1229
|
+
const mockDownloader = mock(async () => "/tmp/test-image.jpg");
|
|
1230
|
+
createSlackApp({ fileDownloaderFn: mockDownloader });
|
|
1231
|
+
|
|
1232
|
+
const client = makeMockClient();
|
|
1233
|
+
const say = makeSay();
|
|
1234
|
+
await capturedMessageHandler?.({
|
|
1235
|
+
message: {
|
|
1236
|
+
channel: "D1",
|
|
1237
|
+
ts: "1.1",
|
|
1238
|
+
text: "look at this",
|
|
1239
|
+
channel_type: "im",
|
|
1240
|
+
files: [
|
|
1241
|
+
{
|
|
1242
|
+
name: "photo.jpg",
|
|
1243
|
+
mimetype: "image/jpeg",
|
|
1244
|
+
size: 1000,
|
|
1245
|
+
url_private: "https://files.slack.com/photo.jpg",
|
|
1246
|
+
},
|
|
1247
|
+
],
|
|
1248
|
+
},
|
|
1249
|
+
say,
|
|
1250
|
+
client,
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1254
|
+
"[file: /tmp/test-image.jpg]\nlook at this",
|
|
1255
|
+
"D1:1.1",
|
|
1256
|
+
expect.any(Function),
|
|
1257
|
+
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
1258
|
+
);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
test("routes message with files and no text when files download successfully", async () => {
|
|
1262
|
+
const mockDownloader = mock(async () => "/tmp/test.pdf");
|
|
1263
|
+
createSlackApp({ fileDownloaderFn: mockDownloader });
|
|
1264
|
+
|
|
1265
|
+
const client = makeMockClient();
|
|
1266
|
+
const say = makeSay();
|
|
1267
|
+
await capturedMessageHandler?.({
|
|
1268
|
+
message: {
|
|
1269
|
+
channel: "D1",
|
|
1270
|
+
ts: "1.1",
|
|
1271
|
+
channel_type: "im",
|
|
1272
|
+
files: [{ name: "doc.pdf", mimetype: "application/pdf", size: 500 }],
|
|
1273
|
+
},
|
|
1274
|
+
say,
|
|
1275
|
+
client,
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1279
|
+
"[file: /tmp/test.pdf]",
|
|
1280
|
+
"D1:1.1",
|
|
1281
|
+
expect.any(Function),
|
|
1282
|
+
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
1283
|
+
);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
test("continues without file when downloader returns null", async () => {
|
|
1287
|
+
const mockDownloader = mock(async () => null);
|
|
1288
|
+
createSlackApp({ fileDownloaderFn: mockDownloader });
|
|
1289
|
+
|
|
1290
|
+
const client = makeMockClient();
|
|
1291
|
+
const say = makeSay();
|
|
1292
|
+
await capturedMessageHandler?.({
|
|
1293
|
+
message: {
|
|
1294
|
+
channel: "D1",
|
|
1295
|
+
ts: "1.1",
|
|
1296
|
+
text: "here is a file",
|
|
1297
|
+
channel_type: "im",
|
|
1298
|
+
files: [{ name: "big.zip", mimetype: "application/zip", size: 1000 }],
|
|
1299
|
+
},
|
|
1300
|
+
say,
|
|
1301
|
+
client,
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1305
|
+
"here is a file",
|
|
1306
|
+
"D1:1.1",
|
|
1307
|
+
expect.any(Function),
|
|
1308
|
+
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
1309
|
+
);
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
test("continues without file when downloader throws", async () => {
|
|
1313
|
+
const mockDownloader = mock(async () => {
|
|
1314
|
+
throw new Error("network error");
|
|
1315
|
+
});
|
|
1316
|
+
createSlackApp({ fileDownloaderFn: mockDownloader });
|
|
1317
|
+
|
|
1318
|
+
const client = makeMockClient();
|
|
1319
|
+
const say = makeSay();
|
|
1320
|
+
await capturedMessageHandler?.({
|
|
1321
|
+
message: {
|
|
1322
|
+
channel: "D1",
|
|
1323
|
+
ts: "1.1",
|
|
1324
|
+
text: "with erroring file",
|
|
1325
|
+
channel_type: "im",
|
|
1326
|
+
files: [{ name: "err.jpg", mimetype: "image/jpeg", size: 1000 }],
|
|
1327
|
+
},
|
|
1328
|
+
say,
|
|
1329
|
+
client,
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1333
|
+
"with erroring file",
|
|
1334
|
+
"D1:1.1",
|
|
1335
|
+
expect.any(Function),
|
|
1336
|
+
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
1337
|
+
);
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
test("returns early when no text and no files and files array is empty", async () => {
|
|
1341
|
+
createSlackApp();
|
|
1342
|
+
const client = makeMockClient();
|
|
1343
|
+
const say = makeSay();
|
|
1344
|
+
await capturedMessageHandler?.({
|
|
1345
|
+
message: {
|
|
1346
|
+
channel: "D1",
|
|
1347
|
+
ts: "1.1",
|
|
1348
|
+
channel_type: "im",
|
|
1349
|
+
},
|
|
1350
|
+
say,
|
|
1351
|
+
client,
|
|
1352
|
+
});
|
|
1353
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
1354
|
+
});
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
// ─── app_mention handler tests ────────────────────────────────────────────────
|
|
1358
|
+
|
|
1359
|
+
describe("app_mention handler", () => {
|
|
1360
|
+
beforeEach(() => {
|
|
1361
|
+
mockRunClaude.mockClear();
|
|
1362
|
+
mockMarkdownToSlack.mockClear();
|
|
1363
|
+
createSlackApp();
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
async function invokeMention(
|
|
1367
|
+
overrides: Partial<{
|
|
1368
|
+
text: string;
|
|
1369
|
+
channel: string;
|
|
1370
|
+
ts: string;
|
|
1371
|
+
thread_ts: string;
|
|
1372
|
+
user: string;
|
|
1373
|
+
}> = {},
|
|
1374
|
+
) {
|
|
1375
|
+
const client = makeMockClient();
|
|
1376
|
+
const say = makeSay();
|
|
1377
|
+
const event = {
|
|
1378
|
+
text: "<@UBOT> do something",
|
|
1379
|
+
channel: "C999",
|
|
1380
|
+
ts: "222.333",
|
|
1381
|
+
...overrides,
|
|
1382
|
+
};
|
|
1383
|
+
await capturedMentionHandler?.({ event, say, client });
|
|
1384
|
+
return { client, say };
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
test("calls runClaude with event text and thread sessionKey", async () => {
|
|
1388
|
+
await invokeMention({ channel: "C999", ts: "222.333" });
|
|
1389
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1390
|
+
"<@UBOT> do something",
|
|
1391
|
+
"C999:222.333",
|
|
1392
|
+
expect.any(Function),
|
|
1393
|
+
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
1394
|
+
);
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
test("uses thread_ts as sessionKey when mention is in a thread", async () => {
|
|
1398
|
+
await invokeMention({ channel: "C999", ts: "2.2", thread_ts: "1.1" });
|
|
1399
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1400
|
+
"<@UBOT> do something",
|
|
1401
|
+
"C999:1.1",
|
|
1402
|
+
expect.any(Function),
|
|
1403
|
+
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "1.1" },
|
|
1404
|
+
);
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("closes the Thinking Steps stream after a successful mention response", async () => {
|
|
1408
|
+
const { client } = await invokeMention({ channel: "C999", ts: "222.333" });
|
|
1409
|
+
expect(client.chat.stopStream).toHaveBeenCalledWith(
|
|
1410
|
+
expect.objectContaining({ session_status: "active" }),
|
|
1411
|
+
);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
test("a mention reply never posts via client.chat.postMessage — only the Thinking Steps stream or say()", async () => {
|
|
1415
|
+
const { client, say } = await invokeMention({
|
|
1416
|
+
channel: "C999",
|
|
1417
|
+
ts: "222.333",
|
|
1418
|
+
});
|
|
1419
|
+
expect(say).not.toHaveBeenCalled();
|
|
1420
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
test("the mention handler's SlackProgress opens a Thinking Steps stream (STS-1.1)", async () => {
|
|
1424
|
+
createSlackApp();
|
|
1425
|
+
const { client } = await invokeMention({ channel: "C999", ts: "222.333" });
|
|
1426
|
+
// app_mention is never a DM, so recipient_team_id is included — resolved
|
|
1427
|
+
// via MockApp's client.auth.test() mock (STS-2.1 AC #1). No `user` on
|
|
1428
|
+
// this event, so recipient_user_id stays undefined/omitted.
|
|
1429
|
+
expect(client.chat.startStream).toHaveBeenCalledWith({
|
|
1430
|
+
channel: "C999",
|
|
1431
|
+
thread_ts: "222.333",
|
|
1432
|
+
task_display_mode: "timeline",
|
|
1433
|
+
recipient_team_id: "T-TEAM",
|
|
1434
|
+
});
|
|
1435
|
+
expect(client.chat.stopStream).toHaveBeenCalled();
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
// STS-2.1 AC #1: a channel mention includes both recipient fields — the
|
|
1439
|
+
// triggering user's ID (from the event) and the bot's own team ID
|
|
1440
|
+
// (resolved once via client.auth.test() at app construction).
|
|
1441
|
+
test("includes recipient_team_id and recipient_user_id on chat.startStream (STS-2.1 AC #1)", async () => {
|
|
1442
|
+
createSlackApp();
|
|
1443
|
+
const { client } = await invokeMention({
|
|
1444
|
+
channel: "C999",
|
|
1445
|
+
ts: "222.333",
|
|
1446
|
+
user: "U-MENTIONER",
|
|
1447
|
+
});
|
|
1448
|
+
expect(client.chat.startStream).toHaveBeenCalledWith(
|
|
1449
|
+
expect.objectContaining({
|
|
1450
|
+
recipient_team_id: "T-TEAM",
|
|
1451
|
+
recipient_user_id: "U-MENTIONER",
|
|
1452
|
+
}),
|
|
1453
|
+
);
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test("delivers the final reply as a markdown_text chunk via chat.appendStream, and skips say() (STS2-2.1 AC #4)", async () => {
|
|
1457
|
+
createSlackApp();
|
|
1458
|
+
const { client, say } = await invokeMention({
|
|
1459
|
+
channel: "C999",
|
|
1460
|
+
ts: "222.333",
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
const markdownChunks = client.chat.appendStream.mock.calls
|
|
1464
|
+
.flatMap(
|
|
1465
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
1466
|
+
)
|
|
1467
|
+
.filter((chunk) => chunk.type === "markdown_text");
|
|
1468
|
+
expect(markdownChunks).toContainEqual({
|
|
1469
|
+
type: "markdown_text",
|
|
1470
|
+
text: "Claude response text",
|
|
1471
|
+
});
|
|
1472
|
+
// Delivered via the stream — say() is not used for the real reply.
|
|
1473
|
+
expect(say).not.toHaveBeenCalled();
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
test("falls back to say() when chat.appendStream rejects (delivery failure never drops the reply) (STS2-2.1 AC #4)", async () => {
|
|
1477
|
+
const client = makeMockClient();
|
|
1478
|
+
client.chat.appendStream.mockImplementation(async () => {
|
|
1479
|
+
throw new Error("streaming_mode_mismatch");
|
|
1480
|
+
});
|
|
1481
|
+
const say = makeSay();
|
|
1482
|
+
createSlackApp();
|
|
1483
|
+
await capturedMentionHandler?.({
|
|
1484
|
+
event: {
|
|
1485
|
+
text: "<@UBOT> do something",
|
|
1486
|
+
channel: "C999",
|
|
1487
|
+
ts: "222.333",
|
|
1488
|
+
},
|
|
1489
|
+
say,
|
|
1490
|
+
client,
|
|
1491
|
+
});
|
|
1492
|
+
expect(say).toHaveBeenCalledWith({
|
|
1493
|
+
text: "[formatted] Claude response text",
|
|
1494
|
+
thread_ts: "222.333",
|
|
1495
|
+
});
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
test("passes an onProgress function through to the runner as the third arg on mention (AC #1)", async () => {
|
|
1499
|
+
await invokeMention({ channel: "C999", ts: "222.333" });
|
|
1500
|
+
const call = mockRunClaude.mock.calls.at(-1) as unknown[];
|
|
1501
|
+
expect(call[2]).toBeTypeOf("function");
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
test("posts error message when runClaude throws on mention", async () => {
|
|
1505
|
+
mockRunClaude.mockRejectedValueOnce(new Error("timeout"));
|
|
1506
|
+
const { say } = await invokeMention({ ts: "2.2" });
|
|
1507
|
+
expect(say).toHaveBeenCalledWith({
|
|
1508
|
+
text: expect.stringContaining("timeout"),
|
|
1509
|
+
thread_ts: "2.2",
|
|
1510
|
+
});
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
test("closes the Thinking Steps stream after mention handler error", async () => {
|
|
1514
|
+
mockRunClaude.mockRejectedValueOnce(new Error("timeout"));
|
|
1515
|
+
const { client } = await invokeMention({ channel: "C999", ts: "2.2" });
|
|
1516
|
+
expect(client.chat.stopStream).toHaveBeenCalledWith(
|
|
1517
|
+
expect.objectContaining({ session_status: "active" }),
|
|
1518
|
+
);
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
test("does not throw when chat.stopStream fails on mention", async () => {
|
|
1522
|
+
const client = makeMockClient();
|
|
1523
|
+
const say = makeSay();
|
|
1524
|
+
client.chat.stopStream.mockRejectedValueOnce(new Error("api error"));
|
|
1525
|
+
|
|
1526
|
+
await expect(
|
|
1527
|
+
capturedMentionHandler?.({
|
|
1528
|
+
event: { text: "hi", channel: "C1", ts: "1.1" },
|
|
1529
|
+
say,
|
|
1530
|
+
client,
|
|
1531
|
+
}),
|
|
1532
|
+
).resolves.toBeUndefined();
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
test("does not call sentryClient.captureException on mention success", async () => {
|
|
1536
|
+
await invokeMention({ channel: "C1", ts: "2.2", text: "@bot help" });
|
|
1537
|
+
expect(capturedErrors.length).toBe(0);
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
test("calls sentryClient.captureException when mention handler throws", async () => {
|
|
1541
|
+
mockRunClaude.mockRejectedValueOnce(new Error("oops"));
|
|
1542
|
+
await invokeMention({ channel: "C1", ts: "2.2" });
|
|
1543
|
+
expect(capturedErrors.length).toBe(1);
|
|
1544
|
+
expect((capturedErrors[0] as Error).message).toBe("oops");
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
test("SENTRY-46: a ceiling-reason ClaudeTimeoutError reports via captureMessage, not captureException (mention)", async () => {
|
|
1548
|
+
mockRunClaude.mockRejectedValueOnce(
|
|
1549
|
+
new ClaudeTimeoutError(3_600_000, "ceiling"),
|
|
1550
|
+
);
|
|
1551
|
+
await invokeMention({ channel: "C1", ts: "2.2" });
|
|
1552
|
+
expect(capturedErrors.length).toBe(0);
|
|
1553
|
+
expect(capturedMessages.length).toBe(1);
|
|
1554
|
+
expect(capturedMessages[0]).toContain("3600s");
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
test("an idle-reason ClaudeTimeoutError still reports via captureException (mention)", async () => {
|
|
1558
|
+
mockRunClaude.mockRejectedValueOnce(
|
|
1559
|
+
new ClaudeTimeoutError(1_500_000, "idle"),
|
|
1560
|
+
);
|
|
1561
|
+
await invokeMention({ channel: "C1", ts: "2.2" });
|
|
1562
|
+
expect(capturedMessages.length).toBe(0);
|
|
1563
|
+
expect(capturedErrors.length).toBe(1);
|
|
1564
|
+
expect((capturedErrors[0] as ClaudeTimeoutError).reason).toBe("idle");
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
test("marks the Thinking Steps card status:'error' when mention handler throws (STS2-4.1 AC #1)", async () => {
|
|
1568
|
+
createSlackApp();
|
|
1569
|
+
mockRunClaude.mockRejectedValueOnce(new Error("oops"));
|
|
1570
|
+
const { client } = await invokeMention({ channel: "C999", ts: "222.333" });
|
|
1571
|
+
|
|
1572
|
+
const taskUpdateChunks = client.chat.appendStream.mock.calls
|
|
1573
|
+
.flatMap(
|
|
1574
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
1575
|
+
)
|
|
1576
|
+
.filter((chunk) => chunk.type === "task_update");
|
|
1577
|
+
const errorChunks = taskUpdateChunks.filter(
|
|
1578
|
+
(chunk) => chunk.status === "error",
|
|
1579
|
+
);
|
|
1580
|
+
expect(errorChunks.length).toBeGreaterThan(0);
|
|
1581
|
+
|
|
1582
|
+
expect(client.chat.stopStream).toHaveBeenCalled();
|
|
1583
|
+
const completeChunks = taskUpdateChunks.filter(
|
|
1584
|
+
(chunk) => chunk.status === "complete",
|
|
1585
|
+
);
|
|
1586
|
+
expect(completeChunks.length).toBe(0);
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
test("marks the card status:'error' even when the catch block's say() itself throws on mention (STS2-4.1 AC #1 — #3064 review)", async () => {
|
|
1590
|
+
// Both the run AND the error-notification say() fail. reportError() must
|
|
1591
|
+
// still fire (it runs before say() in the catch block), so finish() never
|
|
1592
|
+
// sends a "Done" card for a run that both errored and failed to notify.
|
|
1593
|
+
const client = makeMockClient();
|
|
1594
|
+
const say = mock(async (_args: unknown) => {
|
|
1595
|
+
throw new Error("invalid_auth");
|
|
1596
|
+
});
|
|
1597
|
+
createSlackApp();
|
|
1598
|
+
mockRunClaude.mockRejectedValueOnce(new Error("oops"));
|
|
1599
|
+
|
|
1600
|
+
await capturedMentionHandler?.({
|
|
1601
|
+
event: { text: "<@UBOT> do something", channel: "C999", ts: "222.333" },
|
|
1602
|
+
say,
|
|
1603
|
+
client,
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
const taskUpdateChunks = client.chat.appendStream.mock.calls
|
|
1607
|
+
.flatMap(
|
|
1608
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
1609
|
+
)
|
|
1610
|
+
.filter((chunk) => chunk.type === "task_update");
|
|
1611
|
+
const errorChunks = taskUpdateChunks.filter(
|
|
1612
|
+
(chunk) => chunk.status === "error",
|
|
1613
|
+
);
|
|
1614
|
+
expect(errorChunks.length).toBeGreaterThan(0);
|
|
1615
|
+
|
|
1616
|
+
const completeChunks = taskUpdateChunks.filter(
|
|
1617
|
+
(chunk) => chunk.status === "complete",
|
|
1618
|
+
);
|
|
1619
|
+
expect(completeChunks.length).toBe(0);
|
|
1620
|
+
expect(client.chat.stopStream).toHaveBeenCalled();
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
test("streamIncomplete:true on mention is treated as an error, not a silent empty success (CSU-1.1 regression)", async () => {
|
|
1624
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
1625
|
+
result: "",
|
|
1626
|
+
streamIncomplete: true,
|
|
1627
|
+
});
|
|
1628
|
+
const { say } = await invokeMention({ channel: "C1", ts: "2.2" });
|
|
1629
|
+
|
|
1630
|
+
expect(capturedErrors.length).toBe(1);
|
|
1631
|
+
expect((capturedErrors[0] as Error).message).toContain(
|
|
1632
|
+
"stream ended without a terminal result event",
|
|
1633
|
+
);
|
|
1634
|
+
expect(say).toHaveBeenCalledTimes(1);
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
test("app_mention in active thread: runClaude not called when session exists", async () => {
|
|
1638
|
+
// The fix: when thread_ts is set and getSessionFn returns a session, the
|
|
1639
|
+
// app_mention handler returns early — the message handler already covers it
|
|
1640
|
+
// and would double-respond otherwise.
|
|
1641
|
+
createSlackApp({ getSessionFn: async () => "sess-existing" });
|
|
1642
|
+
const client = makeMockClient();
|
|
1643
|
+
const say = makeSay();
|
|
1644
|
+
await capturedMentionHandler?.({
|
|
1645
|
+
event: {
|
|
1646
|
+
text: "<@UBOT> follow-up in thread",
|
|
1647
|
+
channel: "C999",
|
|
1648
|
+
ts: "3.3",
|
|
1649
|
+
thread_ts: "1.0",
|
|
1650
|
+
},
|
|
1651
|
+
say,
|
|
1652
|
+
client,
|
|
1653
|
+
});
|
|
1654
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
1655
|
+
});
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
// ─── app_mention handler — file handling ─────────────────────────────────────
|
|
1659
|
+
|
|
1660
|
+
describe("app_mention handler — file handling", () => {
|
|
1661
|
+
beforeEach(() => {
|
|
1662
|
+
mockRunClaude.mockClear();
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
async function invokeMentionWithFiles(
|
|
1666
|
+
overrides: {
|
|
1667
|
+
files?: SlackFile[];
|
|
1668
|
+
text?: string;
|
|
1669
|
+
fileDownloaderFn?: (
|
|
1670
|
+
file: SlackFile,
|
|
1671
|
+
botToken: string,
|
|
1672
|
+
) => Promise<string | null>;
|
|
1673
|
+
transcribeAudioFn?: TranscribeAudioFn;
|
|
1674
|
+
} = {},
|
|
1675
|
+
) {
|
|
1676
|
+
const {
|
|
1677
|
+
files,
|
|
1678
|
+
text = "<@UBOT> look at this",
|
|
1679
|
+
fileDownloaderFn,
|
|
1680
|
+
transcribeAudioFn,
|
|
1681
|
+
} = overrides;
|
|
1682
|
+
createSlackApp({
|
|
1683
|
+
fileDownloaderFn: fileDownloaderFn ?? (async () => null),
|
|
1684
|
+
transcribeAudioFn: transcribeAudioFn ?? (async () => null),
|
|
1685
|
+
});
|
|
1686
|
+
const client = makeMockClient();
|
|
1687
|
+
const say = makeSay();
|
|
1688
|
+
const event = {
|
|
1689
|
+
text,
|
|
1690
|
+
channel: "C999",
|
|
1691
|
+
ts: "222.333",
|
|
1692
|
+
...(files !== undefined ? { files } : {}),
|
|
1693
|
+
};
|
|
1694
|
+
await capturedMentionHandler?.({ event, say, client });
|
|
1695
|
+
return { client, say };
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
test("injects file path into prompt when file attached to @mention downloads successfully", async () => {
|
|
1699
|
+
const mockDownloader = mock(async () => "/tmp/test-mention-image.jpg");
|
|
1700
|
+
await invokeMentionWithFiles({
|
|
1701
|
+
fileDownloaderFn: mockDownloader,
|
|
1702
|
+
files: [
|
|
1703
|
+
{
|
|
1704
|
+
name: "photo.jpg",
|
|
1705
|
+
mimetype: "image/jpeg",
|
|
1706
|
+
size: 1000,
|
|
1707
|
+
url_private: "https://files.slack.com/photo.jpg",
|
|
1708
|
+
},
|
|
1709
|
+
],
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1713
|
+
"[file: /tmp/test-mention-image.jpg]\n<@UBOT> look at this",
|
|
1714
|
+
"C999:222.333",
|
|
1715
|
+
expect.any(Function),
|
|
1716
|
+
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
1717
|
+
);
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
test("transcribes audio file attached to @mention and injects [voice transcript: ...]", async () => {
|
|
1721
|
+
const mockTranscribe = mock(
|
|
1722
|
+
async (_path: string, _cfg: unknown) => "hello from voice",
|
|
1723
|
+
);
|
|
1724
|
+
const mockDownloader = mock(async () => "/tmp/mention-voice.webm");
|
|
1725
|
+
await invokeMentionWithFiles({
|
|
1726
|
+
fileDownloaderFn: mockDownloader,
|
|
1727
|
+
transcribeAudioFn: mockTranscribe,
|
|
1728
|
+
files: [{ name: "voice.webm", mimetype: "audio/webm", size: 1000 }],
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
expect(mockTranscribe).toHaveBeenCalledTimes(1);
|
|
1732
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
1733
|
+
expect(prompt).toContain("[voice transcript: hello from voice]");
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
test("continues without file when downloader returns null on @mention", async () => {
|
|
1737
|
+
const mockDownloader = mock(async () => null);
|
|
1738
|
+
await invokeMentionWithFiles({
|
|
1739
|
+
fileDownloaderFn: mockDownloader,
|
|
1740
|
+
files: [{ name: "big.zip", mimetype: "application/zip", size: 1000 }],
|
|
1741
|
+
text: "<@UBOT> here is a file",
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1745
|
+
"<@UBOT> here is a file",
|
|
1746
|
+
"C999:222.333",
|
|
1747
|
+
expect.any(Function),
|
|
1748
|
+
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
1749
|
+
);
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
test("continues without file when downloader throws on @mention", async () => {
|
|
1753
|
+
const mockDownloader = mock(async () => {
|
|
1754
|
+
throw new Error("network error");
|
|
1755
|
+
});
|
|
1756
|
+
await invokeMentionWithFiles({
|
|
1757
|
+
fileDownloaderFn: mockDownloader,
|
|
1758
|
+
files: [{ name: "err.jpg", mimetype: "image/jpeg", size: 1000 }],
|
|
1759
|
+
text: "<@UBOT> with erroring file",
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1763
|
+
"<@UBOT> with erroring file",
|
|
1764
|
+
"C999:222.333",
|
|
1765
|
+
expect.any(Function),
|
|
1766
|
+
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
1767
|
+
);
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
test("falls back to [file: ...] when audio transcription returns null on @mention", async () => {
|
|
1771
|
+
const mockTranscribe = mock(async () => null);
|
|
1772
|
+
const mockDownloader = mock(async () => "/tmp/mention-voice.webm");
|
|
1773
|
+
await invokeMentionWithFiles({
|
|
1774
|
+
fileDownloaderFn: mockDownloader,
|
|
1775
|
+
transcribeAudioFn: mockTranscribe,
|
|
1776
|
+
files: [{ name: "voice.webm", mimetype: "audio/webm", size: 1000 }],
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
expect(mockTranscribe).toHaveBeenCalledTimes(1);
|
|
1780
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
1781
|
+
expect(prompt).toContain("[file: /tmp/mention-voice.webm]");
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
test("does not transcribe non-audio file attached to @mention", async () => {
|
|
1785
|
+
const mockTranscribe = mock(async () => "should not be called");
|
|
1786
|
+
const mockDownloader = mock(async () => "/tmp/mention-image.jpg");
|
|
1787
|
+
await invokeMentionWithFiles({
|
|
1788
|
+
fileDownloaderFn: mockDownloader,
|
|
1789
|
+
transcribeAudioFn: mockTranscribe,
|
|
1790
|
+
files: [{ name: "photo.jpg", mimetype: "image/jpeg", size: 1000 }],
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
expect(mockTranscribe).not.toHaveBeenCalled();
|
|
1794
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
1795
|
+
"[file: /tmp/mention-image.jpg]\n<@UBOT> look at this",
|
|
1796
|
+
"C999:222.333",
|
|
1797
|
+
expect.any(Function),
|
|
1798
|
+
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
1799
|
+
);
|
|
1800
|
+
});
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
// ─── downloadFile unit tests ──────────────────────────────────────────────────
|
|
1804
|
+
|
|
1805
|
+
describe("downloadFile", () => {
|
|
1806
|
+
test("returns null for files exceeding 10MB", async () => {
|
|
1807
|
+
const file: SlackFile = {
|
|
1808
|
+
name: "big.bin",
|
|
1809
|
+
mimetype: "application/octet-stream",
|
|
1810
|
+
size: 11 * 1024 * 1024,
|
|
1811
|
+
url_private: "https://files.slack.com/big.bin",
|
|
1812
|
+
};
|
|
1813
|
+
const result = await downloadFile(file, "xoxb-token");
|
|
1814
|
+
expect(result).toBeNull();
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
test("returns null when url_private is missing", async () => {
|
|
1818
|
+
const file: SlackFile = {
|
|
1819
|
+
name: "nurl.txt",
|
|
1820
|
+
mimetype: "text/plain",
|
|
1821
|
+
size: 100,
|
|
1822
|
+
};
|
|
1823
|
+
const result = await downloadFile(file, "xoxb-token");
|
|
1824
|
+
expect(result).toBeNull();
|
|
1825
|
+
});
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
// ─── Marker dispatch — DM handler ─────────────────────────────────────────────
|
|
1829
|
+
|
|
1830
|
+
describe("marker dispatch — DM message handler", () => {
|
|
1831
|
+
beforeEach(() => {
|
|
1832
|
+
mockRunClaude.mockClear();
|
|
1833
|
+
createSlackApp();
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
async function invokeDMWithResult(result: string) {
|
|
1837
|
+
mockRunClaude.mockResolvedValueOnce({ result, sessionId: "sess-1" });
|
|
1838
|
+
const client = makeMockClient();
|
|
1839
|
+
const say = makeSay();
|
|
1840
|
+
await capturedMessageHandler?.({
|
|
1841
|
+
message: {
|
|
1842
|
+
channel: "D1",
|
|
1843
|
+
ts: "1.1",
|
|
1844
|
+
text: "hello",
|
|
1845
|
+
channel_type: "im",
|
|
1846
|
+
},
|
|
1847
|
+
say,
|
|
1848
|
+
client,
|
|
1849
|
+
});
|
|
1850
|
+
return { client, say };
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
test("[silent] — skips say() and closes the Thinking Steps stream", async () => {
|
|
1854
|
+
const { say, client } = await invokeDMWithResult("[silent]");
|
|
1855
|
+
expect(say).not.toHaveBeenCalled();
|
|
1856
|
+
expect(client.chat.stopStream).toHaveBeenCalledWith(
|
|
1857
|
+
expect.objectContaining({ session_status: "active" }),
|
|
1858
|
+
);
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
test("[silent] with content — DM ignores silent and posts anyway", async () => {
|
|
1862
|
+
const { say, client } = await invokeDMWithResult(
|
|
1863
|
+
"Here is your answer.\n[silent]",
|
|
1864
|
+
);
|
|
1865
|
+
expect(say).not.toHaveBeenCalled();
|
|
1866
|
+
const markdownChunks = client.chat.appendStream.mock.calls
|
|
1867
|
+
.flatMap(
|
|
1868
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
1869
|
+
)
|
|
1870
|
+
.filter((chunk) => chunk.type === "markdown_text");
|
|
1871
|
+
expect(markdownChunks).toHaveLength(1);
|
|
1872
|
+
const text = markdownChunks[0]?.text as string;
|
|
1873
|
+
expect(text).toContain("Here is your answer.");
|
|
1874
|
+
expect(text).not.toContain("[silent]");
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
test("[upload:/path] — uploads file to Slack", async () => {
|
|
1878
|
+
// Write a temp file to upload
|
|
1879
|
+
const tmpPath = join(tmpdir(), `test-upload-${Date.now()}.txt`);
|
|
1880
|
+
writeFileSync(tmpPath, "upload content");
|
|
1881
|
+
const { client } = await invokeDMWithResult(`[upload:${tmpPath}]`);
|
|
1882
|
+
expect(client.files.uploadV2).toHaveBeenCalledTimes(1);
|
|
1883
|
+
const uploadCall = (
|
|
1884
|
+
client.files.uploadV2.mock.calls[0] as [Record<string, unknown>]
|
|
1885
|
+
)[0];
|
|
1886
|
+
expect(uploadCall.channel_id).toBe("D1");
|
|
1887
|
+
expect(uploadCall.filename).toBe(tmpPath.split("/").pop());
|
|
1888
|
+
unlinkSync(tmpPath);
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
test("[upload:/path] — skips upload when file does not exist", async () => {
|
|
1892
|
+
const { client } = await invokeDMWithResult(
|
|
1893
|
+
"[upload:/nonexistent/file.txt]",
|
|
1894
|
+
);
|
|
1895
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
1896
|
+
});
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
// ─── Marker dispatch — app_mention handler ────────────────────────────────────
|
|
1900
|
+
|
|
1901
|
+
describe("marker dispatch — app_mention handler", () => {
|
|
1902
|
+
beforeEach(() => {
|
|
1903
|
+
mockRunClaude.mockClear();
|
|
1904
|
+
createSlackApp();
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
async function invokeMentionWithResult(result: string) {
|
|
1908
|
+
mockRunClaude.mockResolvedValueOnce({ result, sessionId: "sess-1" });
|
|
1909
|
+
const client = makeMockClient();
|
|
1910
|
+
const say = makeSay();
|
|
1911
|
+
await capturedMentionHandler?.({
|
|
1912
|
+
event: { text: "@bot help", channel: "C1", ts: "1.1" },
|
|
1913
|
+
say,
|
|
1914
|
+
client,
|
|
1915
|
+
});
|
|
1916
|
+
return { client, say };
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
test("[silent] — skips say() in mention handler", async () => {
|
|
1920
|
+
const { say } = await invokeMentionWithResult("[silent]");
|
|
1921
|
+
expect(say).not.toHaveBeenCalled();
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
test("[upload:/path] — skips upload when file not found in mention handler", async () => {
|
|
1925
|
+
const { client } = await invokeMentionWithResult(
|
|
1926
|
+
"Here [upload:/nonexistent/mention-file.txt]",
|
|
1927
|
+
);
|
|
1928
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
test("[upload:/path] — uploads file in mention handler", async () => {
|
|
1932
|
+
const tmpPath = join(tmpdir(), `test-mention-upload-${Date.now()}.txt`);
|
|
1933
|
+
writeFileSync(tmpPath, "mention upload content");
|
|
1934
|
+
const { client } = await invokeMentionWithResult(`[upload:${tmpPath}]`);
|
|
1935
|
+
expect(client.files.uploadV2).toHaveBeenCalledTimes(1);
|
|
1936
|
+
const uploadCall = (
|
|
1937
|
+
client.files.uploadV2.mock.calls[0] as [Record<string, unknown>]
|
|
1938
|
+
)[0];
|
|
1939
|
+
expect(uploadCall.channel_id).toBe("C1");
|
|
1940
|
+
unlinkSync(tmpPath);
|
|
1941
|
+
});
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
// ─── Voice integration tests ──────────────────────────────────────────────────
|
|
1945
|
+
|
|
1946
|
+
describe("voice integration — audio transcription in DM handler", () => {
|
|
1947
|
+
beforeEach(() => {
|
|
1948
|
+
mockRunClaude.mockClear();
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
test("transcribes audio/* file and passes transcript to Claude", async () => {
|
|
1952
|
+
const mockTranscribe = mock(
|
|
1953
|
+
async (_path: string, _cfg: unknown) => "hello from audio",
|
|
1954
|
+
);
|
|
1955
|
+
const mockDownloader = mock(async () => "/tmp/test-voice.webm");
|
|
1956
|
+
createSlackApp({
|
|
1957
|
+
transcribeAudioFn: mockTranscribe,
|
|
1958
|
+
fileDownloaderFn: mockDownloader,
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
const client = makeMockClient();
|
|
1962
|
+
const say = makeSay();
|
|
1963
|
+
await capturedMessageHandler?.({
|
|
1964
|
+
message: {
|
|
1965
|
+
channel: "D1",
|
|
1966
|
+
ts: "1.1",
|
|
1967
|
+
text: "listen to this",
|
|
1968
|
+
channel_type: "im",
|
|
1969
|
+
files: [{ name: "voice.webm", mimetype: "audio/webm", size: 1000 }],
|
|
1970
|
+
},
|
|
1971
|
+
say,
|
|
1972
|
+
client,
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
expect(mockTranscribe).toHaveBeenCalledTimes(1);
|
|
1976
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
1977
|
+
expect(prompt).toContain("[voice transcript: hello from audio]");
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
test("falls back to [file:] when transcription returns null", async () => {
|
|
1981
|
+
const mockTranscribe = mock(async () => null);
|
|
1982
|
+
const mockDownloader = mock(async () => "/tmp/test-audio.webm");
|
|
1983
|
+
createSlackApp({
|
|
1984
|
+
transcribeAudioFn: mockTranscribe,
|
|
1985
|
+
fileDownloaderFn: mockDownloader,
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
const client = makeMockClient();
|
|
1989
|
+
const say = makeSay();
|
|
1990
|
+
await capturedMessageHandler?.({
|
|
1991
|
+
message: {
|
|
1992
|
+
channel: "D1",
|
|
1993
|
+
ts: "1.1",
|
|
1994
|
+
text: "audio",
|
|
1995
|
+
channel_type: "im",
|
|
1996
|
+
files: [{ name: "voice.webm", mimetype: "audio/webm", size: 1000 }],
|
|
1997
|
+
},
|
|
1998
|
+
say,
|
|
1999
|
+
client,
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
2003
|
+
expect(prompt).toContain("[file: /tmp/test-audio.webm]");
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
test("non-audio files are not transcribed", async () => {
|
|
2007
|
+
const mockTranscribe = mock(async () => "should not be called");
|
|
2008
|
+
const mockDownloader = mock(async () => "/tmp/image.jpg");
|
|
2009
|
+
createSlackApp({
|
|
2010
|
+
transcribeAudioFn: mockTranscribe,
|
|
2011
|
+
fileDownloaderFn: mockDownloader,
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
const client = makeMockClient();
|
|
2015
|
+
const say = makeSay();
|
|
2016
|
+
await capturedMessageHandler?.({
|
|
2017
|
+
message: {
|
|
2018
|
+
channel: "D1",
|
|
2019
|
+
ts: "1.1",
|
|
2020
|
+
text: "image",
|
|
2021
|
+
channel_type: "im",
|
|
2022
|
+
files: [{ name: "photo.jpg", mimetype: "image/jpeg", size: 1000 }],
|
|
2023
|
+
},
|
|
2024
|
+
say,
|
|
2025
|
+
client,
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
expect(mockTranscribe).not.toHaveBeenCalled();
|
|
2029
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
2030
|
+
expect(prompt).toContain("[file: /tmp/image.jpg]");
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
test("audio/m4a file → transcribeAudioFn called with downloaded path → prompt contains [voice transcript: ...]", async () => {
|
|
2034
|
+
const mockTranscribe = mock(
|
|
2035
|
+
async (_path: string, _cfg: unknown) => "transcribed voice note",
|
|
2036
|
+
);
|
|
2037
|
+
const mockDownloader = mock(async () => "/tmp/voice-note.m4a");
|
|
2038
|
+
createSlackApp({
|
|
2039
|
+
transcribeAudioFn: mockTranscribe,
|
|
2040
|
+
fileDownloaderFn: mockDownloader,
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
const client = makeMockClient();
|
|
2044
|
+
const say = makeSay();
|
|
2045
|
+
await capturedMessageHandler?.({
|
|
2046
|
+
message: {
|
|
2047
|
+
channel: "D1",
|
|
2048
|
+
ts: "1.1",
|
|
2049
|
+
text: "voice note",
|
|
2050
|
+
channel_type: "im",
|
|
2051
|
+
files: [{ name: "voice.m4a", mimetype: "audio/m4a", size: 2000 }],
|
|
2052
|
+
},
|
|
2053
|
+
say,
|
|
2054
|
+
client,
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
expect(mockTranscribe).toHaveBeenCalledTimes(1);
|
|
2058
|
+
expect(mockTranscribe).toHaveBeenCalledWith("/tmp/voice-note.m4a", {});
|
|
2059
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
2060
|
+
expect(prompt).toContain("[voice transcript: transcribed voice note]");
|
|
2061
|
+
});
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
// ─── User name context tests ──────────────────────────────────────────────────
|
|
2065
|
+
|
|
2066
|
+
describe("user name context — message handler", () => {
|
|
2067
|
+
beforeEach(() => {
|
|
2068
|
+
mockRunClaude.mockClear();
|
|
2069
|
+
mockResolveUserFn.mockClear();
|
|
2070
|
+
createSlackApp();
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
test("prepends [Name]: to prompt when user field is present", async () => {
|
|
2074
|
+
const client = makeMockClient();
|
|
2075
|
+
const say = makeSay();
|
|
2076
|
+
await capturedMessageHandler?.({
|
|
2077
|
+
message: {
|
|
2078
|
+
channel: "D1",
|
|
2079
|
+
ts: "1.1",
|
|
2080
|
+
text: "hello",
|
|
2081
|
+
channel_type: "im",
|
|
2082
|
+
user: "U123",
|
|
2083
|
+
},
|
|
2084
|
+
say,
|
|
2085
|
+
client,
|
|
2086
|
+
});
|
|
2087
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
2088
|
+
"[Dan]: hello",
|
|
2089
|
+
"D1:1.1",
|
|
2090
|
+
expect.any(Function),
|
|
2091
|
+
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
2092
|
+
);
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
test("does not prepend when user field is absent", async () => {
|
|
2096
|
+
const client = makeMockClient();
|
|
2097
|
+
const say = makeSay();
|
|
2098
|
+
await capturedMessageHandler?.({
|
|
2099
|
+
message: {
|
|
2100
|
+
channel: "D1",
|
|
2101
|
+
ts: "1.1",
|
|
2102
|
+
text: "hello",
|
|
2103
|
+
channel_type: "im",
|
|
2104
|
+
},
|
|
2105
|
+
say,
|
|
2106
|
+
client,
|
|
2107
|
+
});
|
|
2108
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
2109
|
+
"hello",
|
|
2110
|
+
"D1:1.1",
|
|
2111
|
+
expect.any(Function),
|
|
2112
|
+
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
2113
|
+
);
|
|
2114
|
+
});
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
describe("user name context — app_mention handler", () => {
|
|
2118
|
+
beforeEach(() => {
|
|
2119
|
+
mockRunClaude.mockClear();
|
|
2120
|
+
mockResolveUserFn.mockClear();
|
|
2121
|
+
createSlackApp();
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
test("prepends [Name]: to event text when event.user is present", async () => {
|
|
2125
|
+
const client = makeMockClient();
|
|
2126
|
+
const say = makeSay();
|
|
2127
|
+
await capturedMentionHandler?.({
|
|
2128
|
+
event: {
|
|
2129
|
+
text: "<@UBOT> do something",
|
|
2130
|
+
channel: "C999",
|
|
2131
|
+
ts: "222.333",
|
|
2132
|
+
user: "U456",
|
|
2133
|
+
},
|
|
2134
|
+
say,
|
|
2135
|
+
client,
|
|
2136
|
+
});
|
|
2137
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
2138
|
+
"[Dan]: <@UBOT> do something",
|
|
2139
|
+
"C999:222.333",
|
|
2140
|
+
expect.any(Function),
|
|
2141
|
+
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
2142
|
+
);
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
test("does not prepend when event.user is absent", async () => {
|
|
2146
|
+
const client = makeMockClient();
|
|
2147
|
+
const say = makeSay();
|
|
2148
|
+
await capturedMentionHandler?.({
|
|
2149
|
+
event: {
|
|
2150
|
+
text: "<@UBOT> do something",
|
|
2151
|
+
channel: "C999",
|
|
2152
|
+
ts: "222.333",
|
|
2153
|
+
},
|
|
2154
|
+
say,
|
|
2155
|
+
client,
|
|
2156
|
+
});
|
|
2157
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
2158
|
+
"<@UBOT> do something",
|
|
2159
|
+
"C999:222.333",
|
|
2160
|
+
expect.any(Function),
|
|
2161
|
+
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
2162
|
+
);
|
|
2163
|
+
});
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
describe("voice integration — [speak:text] marker dispatch", () => {
|
|
2167
|
+
beforeEach(() => {
|
|
2168
|
+
mockRunClaude.mockClear();
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
test("[speak:text] in DM — synthesizes speech and uploads audio file", async () => {
|
|
2172
|
+
const outPath = join(tmpdir(), `test-speak-${Date.now()}.mp3`);
|
|
2173
|
+
writeFileSync(outPath, Buffer.from("fake audio"));
|
|
2174
|
+
const mockSynthesize = mock(async () => outPath);
|
|
2175
|
+
createSlackApp({ synthesizeSpeechFn: mockSynthesize });
|
|
2176
|
+
|
|
2177
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2178
|
+
result: "Here is the answer. [speak:Here is the answer]",
|
|
2179
|
+
});
|
|
2180
|
+
const client = makeMockClient();
|
|
2181
|
+
const say = makeSay();
|
|
2182
|
+
await capturedMessageHandler?.({
|
|
2183
|
+
message: {
|
|
2184
|
+
channel: "D1",
|
|
2185
|
+
ts: "1.1",
|
|
2186
|
+
text: "speak to me",
|
|
2187
|
+
channel_type: "im",
|
|
2188
|
+
},
|
|
2189
|
+
say,
|
|
2190
|
+
client,
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
expect(mockSynthesize).toHaveBeenCalledWith("Here is the answer", {});
|
|
2194
|
+
expect(client.files.uploadV2).toHaveBeenCalledTimes(1);
|
|
2195
|
+
const uploadArgs = (
|
|
2196
|
+
client.files.uploadV2.mock.calls[0] as [Record<string, unknown>]
|
|
2197
|
+
)[0];
|
|
2198
|
+
expect(uploadArgs.channel_id).toBe("D1");
|
|
2199
|
+
unlinkSync(outPath);
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
test("[speak:text] in DM — posts text fallback when synthesis returns null", async () => {
|
|
2203
|
+
const mockSynthesize = mock(async () => null);
|
|
2204
|
+
createSlackApp({ synthesizeSpeechFn: mockSynthesize });
|
|
2205
|
+
|
|
2206
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2207
|
+
result: "[speak:hello]",
|
|
2208
|
+
});
|
|
2209
|
+
const client = makeMockClient();
|
|
2210
|
+
const say = makeSay();
|
|
2211
|
+
await capturedMessageHandler?.({
|
|
2212
|
+
message: {
|
|
2213
|
+
channel: "D1",
|
|
2214
|
+
ts: "1.1",
|
|
2215
|
+
text: "speak",
|
|
2216
|
+
channel_type: "im",
|
|
2217
|
+
},
|
|
2218
|
+
say,
|
|
2219
|
+
client,
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
2223
|
+
expect(client.chat.postMessage).toHaveBeenCalledWith(
|
|
2224
|
+
expect.objectContaining({ channel: "D1", text: "hello" }),
|
|
2225
|
+
);
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
test("[speak:text] in DM — skips text fallback when synthesis fails and marker echoes the posted reply", async () => {
|
|
2229
|
+
const mockSynthesize = mock(async () => null);
|
|
2230
|
+
createSlackApp({ synthesizeSpeechFn: mockSynthesize });
|
|
2231
|
+
|
|
2232
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2233
|
+
result: "Here is the answer. [speak:Here is the answer]",
|
|
2234
|
+
});
|
|
2235
|
+
const client = makeMockClient();
|
|
2236
|
+
const say = makeSay();
|
|
2237
|
+
await capturedMessageHandler?.({
|
|
2238
|
+
message: {
|
|
2239
|
+
channel: "D1",
|
|
2240
|
+
ts: "1.1",
|
|
2241
|
+
text: "speak to me",
|
|
2242
|
+
channel_type: "im",
|
|
2243
|
+
},
|
|
2244
|
+
say,
|
|
2245
|
+
client,
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
// The visible reply was already posted via say(); the fallback must not
|
|
2249
|
+
// post a near-duplicate copy of it via chat.postMessage.
|
|
2250
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
2251
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
test("[silent] + [speak:text] in channel — synthesis failure still posts fallback (suppressed reply must not count as posted)", async () => {
|
|
2255
|
+
const mockSynthesize = mock(async () => null);
|
|
2256
|
+
createSlackApp({
|
|
2257
|
+
synthesizeSpeechFn: mockSynthesize,
|
|
2258
|
+
getSessionFn: mock(async () => "sess-xyz"),
|
|
2259
|
+
});
|
|
2260
|
+
|
|
2261
|
+
// [silent] suppresses the say() reply in a non-DM channel, but the
|
|
2262
|
+
// [speak:text] marker echoes the suppressed `cleaned` text. Since the
|
|
2263
|
+
// text was never actually posted, the duplicate-content guard must not
|
|
2264
|
+
// treat it as already-shown — the fallback should still post.
|
|
2265
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2266
|
+
result: "Here is the answer. [speak:Here is the answer] [silent]",
|
|
2267
|
+
});
|
|
2268
|
+
const client = makeMockClient();
|
|
2269
|
+
const say = makeSay();
|
|
2270
|
+
await capturedMessageHandler?.({
|
|
2271
|
+
message: {
|
|
2272
|
+
channel: "C123",
|
|
2273
|
+
ts: "2.0",
|
|
2274
|
+
thread_ts: "1.0",
|
|
2275
|
+
text: "hi",
|
|
2276
|
+
channel_type: "channel",
|
|
2277
|
+
},
|
|
2278
|
+
say,
|
|
2279
|
+
client,
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
expect(say).not.toHaveBeenCalled();
|
|
2283
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
2284
|
+
expect(client.chat.postMessage).toHaveBeenCalledWith(
|
|
2285
|
+
expect.objectContaining({ text: "Here is the answer" }),
|
|
2286
|
+
);
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
test("[speak:text] in mention — synthesizes and uploads", async () => {
|
|
2290
|
+
const outPath = join(tmpdir(), `test-speak-mention-${Date.now()}.mp3`);
|
|
2291
|
+
writeFileSync(outPath, Buffer.from("fake audio"));
|
|
2292
|
+
const mockSynthesize = mock(async () => outPath);
|
|
2293
|
+
createSlackApp({ synthesizeSpeechFn: mockSynthesize });
|
|
2294
|
+
|
|
2295
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2296
|
+
result: "Reply. [speak:Reply]",
|
|
2297
|
+
});
|
|
2298
|
+
const client = makeMockClient();
|
|
2299
|
+
const say = makeSay();
|
|
2300
|
+
await capturedMentionHandler?.({
|
|
2301
|
+
event: { text: "@bot speak", channel: "C1", ts: "1.1" },
|
|
2302
|
+
say,
|
|
2303
|
+
client,
|
|
2304
|
+
});
|
|
2305
|
+
|
|
2306
|
+
expect(mockSynthesize).toHaveBeenCalledWith("Reply", {});
|
|
2307
|
+
expect(client.files.uploadV2).toHaveBeenCalledTimes(1);
|
|
2308
|
+
unlinkSync(outPath);
|
|
2309
|
+
});
|
|
2310
|
+
|
|
2311
|
+
test("[speak:hello] → synthesizeSpeechFn('hello', voiceConfig) → files.uploadV2 called with returned audio path", async () => {
|
|
2312
|
+
const outPath = join(tmpdir(), `test-speak-hello-${Date.now()}.mp3`);
|
|
2313
|
+
writeFileSync(outPath, Buffer.from("hello audio"));
|
|
2314
|
+
const testVoiceConfig: VoiceConfig = { voiceId: "test-voice" };
|
|
2315
|
+
const mockSynthesize = mock(async () => outPath);
|
|
2316
|
+
createSlackApp({
|
|
2317
|
+
synthesizeSpeechFn: mockSynthesize,
|
|
2318
|
+
voiceConfig: testVoiceConfig,
|
|
2319
|
+
});
|
|
2320
|
+
|
|
2321
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2322
|
+
result: "[speak:hello]",
|
|
2323
|
+
});
|
|
2324
|
+
const client = makeMockClient();
|
|
2325
|
+
const say = makeSay();
|
|
2326
|
+
await capturedMessageHandler?.({
|
|
2327
|
+
message: {
|
|
2328
|
+
channel: "D1",
|
|
2329
|
+
ts: "1.1",
|
|
2330
|
+
text: "say hello",
|
|
2331
|
+
channel_type: "im",
|
|
2332
|
+
},
|
|
2333
|
+
say,
|
|
2334
|
+
client,
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
expect(mockSynthesize).toHaveBeenCalledTimes(1);
|
|
2338
|
+
expect(mockSynthesize).toHaveBeenCalledWith("hello", testVoiceConfig);
|
|
2339
|
+
expect(client.files.uploadV2).toHaveBeenCalledTimes(1);
|
|
2340
|
+
const uploadArgs = (
|
|
2341
|
+
client.files.uploadV2.mock.calls[0] as [Record<string, unknown>]
|
|
2342
|
+
)[0];
|
|
2343
|
+
const expectedFilename = outPath.split("/").pop();
|
|
2344
|
+
expect(uploadArgs.filename).toBe(expectedFilename);
|
|
2345
|
+
unlinkSync(outPath);
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2348
|
+
test("[speak:text] only response in DM — does not call say, still synthesizes and uploads", async () => {
|
|
2349
|
+
const outPath = join(tmpdir(), `test-speak-only-${Date.now()}.mp3`);
|
|
2350
|
+
writeFileSync(outPath, Buffer.from("audio only"));
|
|
2351
|
+
const mockSynthesize = mock(async () => outPath);
|
|
2352
|
+
createSlackApp({ synthesizeSpeechFn: mockSynthesize });
|
|
2353
|
+
|
|
2354
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2355
|
+
result: "[speak:Got your message loud and clear]",
|
|
2356
|
+
});
|
|
2357
|
+
const client = makeMockClient();
|
|
2358
|
+
const say = makeSay();
|
|
2359
|
+
await capturedMessageHandler?.({
|
|
2360
|
+
message: {
|
|
2361
|
+
channel: "D1",
|
|
2362
|
+
ts: "1.1",
|
|
2363
|
+
text: "hello",
|
|
2364
|
+
channel_type: "im",
|
|
2365
|
+
},
|
|
2366
|
+
say,
|
|
2367
|
+
client,
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
expect(say).not.toHaveBeenCalled();
|
|
2371
|
+
expect(mockSynthesize).toHaveBeenCalledWith(
|
|
2372
|
+
"Got your message loud and clear",
|
|
2373
|
+
{},
|
|
2374
|
+
);
|
|
2375
|
+
expect(client.files.uploadV2).toHaveBeenCalledTimes(1);
|
|
2376
|
+
unlinkSync(outPath);
|
|
2377
|
+
});
|
|
2378
|
+
|
|
2379
|
+
test("[speak:text] only response in mention — does not call say, still synthesizes and uploads", async () => {
|
|
2380
|
+
const outPath = join(tmpdir(), `test-speak-only-mention-${Date.now()}.mp3`);
|
|
2381
|
+
writeFileSync(outPath, Buffer.from("audio only"));
|
|
2382
|
+
const mockSynthesize = mock(async () => outPath);
|
|
2383
|
+
createSlackApp({ synthesizeSpeechFn: mockSynthesize });
|
|
2384
|
+
|
|
2385
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2386
|
+
result: "[speak:On it]",
|
|
2387
|
+
});
|
|
2388
|
+
const client = makeMockClient();
|
|
2389
|
+
const say = makeSay();
|
|
2390
|
+
await capturedMentionHandler?.({
|
|
2391
|
+
event: { text: "@bot hey", channel: "C1", ts: "1.1" },
|
|
2392
|
+
say,
|
|
2393
|
+
client,
|
|
2394
|
+
});
|
|
2395
|
+
|
|
2396
|
+
expect(say).not.toHaveBeenCalled();
|
|
2397
|
+
expect(mockSynthesize).toHaveBeenCalledWith("On it", {});
|
|
2398
|
+
expect(client.files.uploadV2).toHaveBeenCalledTimes(1);
|
|
2399
|
+
unlinkSync(outPath);
|
|
2400
|
+
});
|
|
2401
|
+
|
|
2402
|
+
test("[speak:text] only response in reaction_added — does not call postMessage, still synthesizes and uploads", async () => {
|
|
2403
|
+
const outPath = join(
|
|
2404
|
+
tmpdir(),
|
|
2405
|
+
`test-speak-only-reaction-${Date.now()}.mp3`,
|
|
2406
|
+
);
|
|
2407
|
+
writeFileSync(outPath, Buffer.from("audio only"));
|
|
2408
|
+
const mockSynthesize = mock(async () => outPath);
|
|
2409
|
+
createSlackApp({ synthesizeSpeechFn: mockSynthesize });
|
|
2410
|
+
|
|
2411
|
+
mockRunClaude.mockResolvedValueOnce({ result: "[speak:Noted]" });
|
|
2412
|
+
const client = makeMockClient();
|
|
2413
|
+
await capturedReactionAddedHandler?.({
|
|
2414
|
+
event: {
|
|
2415
|
+
reaction: "thumbsup",
|
|
2416
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
2417
|
+
item_user: "UBOT123",
|
|
2418
|
+
user: "U-DAN",
|
|
2419
|
+
},
|
|
2420
|
+
client,
|
|
2421
|
+
});
|
|
2422
|
+
|
|
2423
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
2424
|
+
expect(mockSynthesize).toHaveBeenCalledWith("Noted", {});
|
|
2425
|
+
expect(client.files.uploadV2).toHaveBeenCalledTimes(1);
|
|
2426
|
+
unlinkSync(outPath);
|
|
2427
|
+
});
|
|
2428
|
+
});
|
|
2429
|
+
|
|
2430
|
+
// ─── voiceConfig presence vs absence ─────────────────────────────────────────
|
|
2431
|
+
|
|
2432
|
+
describe("createSlackApp — voiceConfig option set vs absent", () => {
|
|
2433
|
+
beforeEach(() => {
|
|
2434
|
+
mockRunClaude.mockClear();
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
test("voiceConfig absent — [speak:text] does not upload or error (no synthesizeFn)", async () => {
|
|
2438
|
+
createSlackApp({ synthesizeSpeechFn: async () => null });
|
|
2439
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2440
|
+
result: "[speak:hello]",
|
|
2441
|
+
sessionId: "sess-v1",
|
|
2442
|
+
});
|
|
2443
|
+
const client = makeMockClient();
|
|
2444
|
+
const say = makeSay();
|
|
2445
|
+
await capturedMessageHandler?.({
|
|
2446
|
+
message: { channel: "D1", ts: "1.1", text: "hi", channel_type: "im" },
|
|
2447
|
+
say,
|
|
2448
|
+
client,
|
|
2449
|
+
});
|
|
2450
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2453
|
+
test("voiceConfig present — synthesizeFn called with provided config", async () => {
|
|
2454
|
+
const testVoiceConfig: VoiceConfig = { voiceId: "cfg-voice" };
|
|
2455
|
+
const outPath = join(tmpdir(), `test-vc-present-${Date.now()}.mp3`);
|
|
2456
|
+
writeFileSync(outPath, Buffer.from("audio"));
|
|
2457
|
+
const mockSynthesize = mock(async () => outPath);
|
|
2458
|
+
createSlackApp({
|
|
2459
|
+
voiceConfig: testVoiceConfig,
|
|
2460
|
+
synthesizeSpeechFn: mockSynthesize,
|
|
2461
|
+
});
|
|
2462
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2463
|
+
result: "[speak:test]",
|
|
2464
|
+
sessionId: "sess-v2",
|
|
2465
|
+
});
|
|
2466
|
+
const client = makeMockClient();
|
|
2467
|
+
const say = makeSay();
|
|
2468
|
+
await capturedMessageHandler?.({
|
|
2469
|
+
message: { channel: "D1", ts: "1.2", text: "hi", channel_type: "im" },
|
|
2470
|
+
say,
|
|
2471
|
+
client,
|
|
2472
|
+
});
|
|
2473
|
+
expect(mockSynthesize).toHaveBeenCalledWith("test", testVoiceConfig);
|
|
2474
|
+
unlinkSync(outPath);
|
|
2475
|
+
});
|
|
2476
|
+
});
|
|
2477
|
+
|
|
2478
|
+
// ─── [react:emoji] dispatch — DM message handler ──────────────────────────────
|
|
2479
|
+
|
|
2480
|
+
describe("marker dispatch — [react:emoji] in DM message handler", () => {
|
|
2481
|
+
beforeEach(() => {
|
|
2482
|
+
mockRunClaude.mockClear();
|
|
2483
|
+
createSlackApp();
|
|
2484
|
+
});
|
|
2485
|
+
|
|
2486
|
+
async function invokeDMWithResult(result: string, msgTs = "1.1") {
|
|
2487
|
+
mockRunClaude.mockResolvedValueOnce({ result, sessionId: "sess-1" });
|
|
2488
|
+
const client = makeMockClient();
|
|
2489
|
+
// [react:] only fires against a real posted message ts (postedTs) — the
|
|
2490
|
+
// streaming delivery path never produces one (deliverContent() doesn't
|
|
2491
|
+
// post a separate reactable message), so force the say() fallback to
|
|
2492
|
+
// exercise the marker.
|
|
2493
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
2494
|
+
// makeSay defaults replyTs to "reply.ts.1" — distinct from msgTs
|
|
2495
|
+
const say = makeSay();
|
|
2496
|
+
await capturedMessageHandler?.({
|
|
2497
|
+
message: {
|
|
2498
|
+
channel: "D1",
|
|
2499
|
+
ts: msgTs,
|
|
2500
|
+
text: "hello",
|
|
2501
|
+
channel_type: "im",
|
|
2502
|
+
},
|
|
2503
|
+
say,
|
|
2504
|
+
client,
|
|
2505
|
+
});
|
|
2506
|
+
return { client, say };
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
test("[react:thumbsup] — calls reactions.add with correct args on posted message", async () => {
|
|
2510
|
+
// say returns "reply.ts.1" (the posted reply ts), distinct from the incoming msg ts
|
|
2511
|
+
const { client } = await invokeDMWithResult("Got it! [react:thumbsup]");
|
|
2512
|
+
expect(client.reactions.add).toHaveBeenCalledTimes(1);
|
|
2513
|
+
expect(client.reactions.add).toHaveBeenCalledWith({
|
|
2514
|
+
channel: "D1",
|
|
2515
|
+
timestamp: "reply.ts.1",
|
|
2516
|
+
name: "thumbsup",
|
|
2517
|
+
});
|
|
2518
|
+
});
|
|
2519
|
+
|
|
2520
|
+
test("[react:thumbsup,tada] — calls reactions.add for each emoji", async () => {
|
|
2521
|
+
const { client } = await invokeDMWithResult("Done! [react:thumbsup,tada]");
|
|
2522
|
+
expect(client.reactions.add).toHaveBeenCalledTimes(2);
|
|
2523
|
+
const calls = client.reactions.add.mock.calls as [
|
|
2524
|
+
{ channel: string; timestamp: string; name: string },
|
|
2525
|
+
][];
|
|
2526
|
+
expect(calls[0][0].name).toBe("thumbsup");
|
|
2527
|
+
expect(calls[1][0].name).toBe("tada");
|
|
2528
|
+
});
|
|
2529
|
+
|
|
2530
|
+
test("[react:emoji] is stripped from posted text", async () => {
|
|
2531
|
+
const { say } = await invokeDMWithResult(
|
|
2532
|
+
"Here you go [react:white_check_mark]",
|
|
2533
|
+
);
|
|
2534
|
+
const postedText = (say.mock.calls[0] as [{ text: string }])[0].text;
|
|
2535
|
+
expect(postedText).not.toContain("[react:");
|
|
2536
|
+
});
|
|
2537
|
+
|
|
2538
|
+
test("no reactions.add call when no [react:] marker", async () => {
|
|
2539
|
+
const { client } = await invokeDMWithResult("Plain response");
|
|
2540
|
+
expect(client.reactions.add).not.toHaveBeenCalled();
|
|
2541
|
+
});
|
|
2542
|
+
|
|
2543
|
+
test("[react:emoji] skipped when response is silent", async () => {
|
|
2544
|
+
const { client } = await invokeDMWithResult("[react:thumbsup][silent]");
|
|
2545
|
+
// [silent] in DM with no cleaned text → suppressed; reactions should not be added
|
|
2546
|
+
expect(client.reactions.add).not.toHaveBeenCalled();
|
|
2547
|
+
});
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
// ─── [react:emoji] dispatch — app_mention handler ────────────────────────────
|
|
2551
|
+
|
|
2552
|
+
describe("marker dispatch — [react:emoji] in app_mention handler", () => {
|
|
2553
|
+
beforeEach(() => {
|
|
2554
|
+
mockRunClaude.mockClear();
|
|
2555
|
+
createSlackApp();
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
async function invokeMentionWithResult(result: string) {
|
|
2559
|
+
mockRunClaude.mockResolvedValueOnce({ result, sessionId: "sess-1" });
|
|
2560
|
+
const client = makeMockClient();
|
|
2561
|
+
// [react:] only fires against a real posted message ts (postedTs) — the
|
|
2562
|
+
// streaming delivery path never produces one, so force the say()
|
|
2563
|
+
// fallback to exercise the marker.
|
|
2564
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
2565
|
+
const say = makeSay(); // returns "reply.ts.1"
|
|
2566
|
+
await capturedMentionHandler?.({
|
|
2567
|
+
event: { text: "@bot help", channel: "C1", ts: "1.1" },
|
|
2568
|
+
say,
|
|
2569
|
+
client,
|
|
2570
|
+
});
|
|
2571
|
+
return { client, say };
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
test("[react:rocket] in mention — calls reactions.add on reply", async () => {
|
|
2575
|
+
const { client } = await invokeMentionWithResult("On it! [react:rocket]");
|
|
2576
|
+
expect(client.reactions.add).toHaveBeenCalledTimes(1);
|
|
2577
|
+
expect(client.reactions.add).toHaveBeenCalledWith({
|
|
2578
|
+
channel: "C1",
|
|
2579
|
+
timestamp: "reply.ts.1",
|
|
2580
|
+
name: "rocket",
|
|
2581
|
+
});
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
test("[react:thumbsup,tada] in mention — calls reactions.add for each emoji", async () => {
|
|
2585
|
+
const { client } = await invokeMentionWithResult(
|
|
2586
|
+
"On it! [react:thumbsup,tada]",
|
|
2587
|
+
);
|
|
2588
|
+
expect(client.reactions.add).toHaveBeenCalledTimes(2);
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
test("[react:emoji] stripped from mention reply text", async () => {
|
|
2592
|
+
const { say } = await invokeMentionWithResult(
|
|
2593
|
+
"Done! [react:white_check_mark]",
|
|
2594
|
+
);
|
|
2595
|
+
const postedText = (say.mock.calls[0] as [{ text: string }])[0].text;
|
|
2596
|
+
expect(postedText).not.toContain("[react:");
|
|
2597
|
+
});
|
|
2598
|
+
});
|
|
2599
|
+
|
|
2600
|
+
// ─── reaction_added handler ───────────────────────────────────────────────────
|
|
2601
|
+
|
|
2602
|
+
describe("reaction_added handler", () => {
|
|
2603
|
+
beforeEach(() => {
|
|
2604
|
+
mockRunClaude.mockClear();
|
|
2605
|
+
});
|
|
2606
|
+
|
|
2607
|
+
async function invokeReactionAdded(
|
|
2608
|
+
overrides: {
|
|
2609
|
+
reaction?: string;
|
|
2610
|
+
item?: { type: string; channel: string; ts: string };
|
|
2611
|
+
item_user?: string;
|
|
2612
|
+
user?: string;
|
|
2613
|
+
client?: ReturnType<typeof makeMockClient>;
|
|
2614
|
+
} = {},
|
|
2615
|
+
) {
|
|
2616
|
+
const {
|
|
2617
|
+
reaction = "thumbsup",
|
|
2618
|
+
item = { type: "message", channel: "D1", ts: "100.1" },
|
|
2619
|
+
item_user = "UBOT123",
|
|
2620
|
+
user = "U-DAN",
|
|
2621
|
+
} = overrides;
|
|
2622
|
+
|
|
2623
|
+
createSlackApp();
|
|
2624
|
+
const client = overrides.client ?? makeMockClient();
|
|
2625
|
+
await capturedReactionAddedHandler?.({
|
|
2626
|
+
event: { reaction, item, item_user, user },
|
|
2627
|
+
client,
|
|
2628
|
+
});
|
|
2629
|
+
return { client };
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
/** A client with delivery forced to fail, so client.chat.postMessage's
|
|
2633
|
+
* say()-equivalent fallback path (and its postedTs) gets exercised —
|
|
2634
|
+
* the streaming path never produces a reactable message ts. */
|
|
2635
|
+
function makeFallbackForcedClient() {
|
|
2636
|
+
const client = makeMockClient();
|
|
2637
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
2638
|
+
return client;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
test("registers a reaction_added event handler", () => {
|
|
2642
|
+
createSlackApp();
|
|
2643
|
+
expect(capturedReactionAddedHandler).toBeTypeOf("function");
|
|
2644
|
+
});
|
|
2645
|
+
|
|
2646
|
+
test("invokes Claude with the session key for the reacted message", async () => {
|
|
2647
|
+
await invokeReactionAdded();
|
|
2648
|
+
expect(mockRunClaude).toHaveBeenCalledTimes(1);
|
|
2649
|
+
// Runner receives the session key — it resolves the session ID internally
|
|
2650
|
+
expect(mockRunClaude.mock.calls[0][1]).toBe("D1:100.1");
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
test("passes an onProgress function through to the runner as the third arg (AC #1)", async () => {
|
|
2654
|
+
await invokeReactionAdded();
|
|
2655
|
+
expect(mockRunClaude.mock.calls[0][2]).toBeTypeOf("function");
|
|
2656
|
+
});
|
|
2657
|
+
|
|
2658
|
+
test("delivers the reaction reply via the Thinking Steps stream, not client.chat.postMessage", async () => {
|
|
2659
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2660
|
+
result: "Logged your walk!",
|
|
2661
|
+
sessionId: "sess-progress-1",
|
|
2662
|
+
});
|
|
2663
|
+
const { client } = await invokeReactionAdded();
|
|
2664
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
2665
|
+
const markdownChunks = client.chat.appendStream.mock.calls
|
|
2666
|
+
.flatMap(
|
|
2667
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
2668
|
+
)
|
|
2669
|
+
.filter((chunk) => chunk.type === "markdown_text");
|
|
2670
|
+
expect(markdownChunks).toContainEqual({
|
|
2671
|
+
type: "markdown_text",
|
|
2672
|
+
text: "Logged your walk!",
|
|
2673
|
+
});
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2676
|
+
test("the reaction_added handler's SlackProgress opens a Thinking Steps stream (STS-1.1)", async () => {
|
|
2677
|
+
createSlackApp();
|
|
2678
|
+
const client = makeMockClient();
|
|
2679
|
+
await capturedReactionAddedHandler?.({
|
|
2680
|
+
event: {
|
|
2681
|
+
reaction: "thumbsup",
|
|
2682
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
2683
|
+
item_user: "UBOT123",
|
|
2684
|
+
user: "U-DAN",
|
|
2685
|
+
},
|
|
2686
|
+
client,
|
|
2687
|
+
});
|
|
2688
|
+
// reaction_added is gated to DM channels only (`channel.startsWith("D")`)
|
|
2689
|
+
// — recipient_team_id/recipient_user_id are always omitted here even
|
|
2690
|
+
// though a `user` is present on the event (STS-2.1 AC #2).
|
|
2691
|
+
expect(client.chat.startStream).toHaveBeenCalledWith({
|
|
2692
|
+
channel: "D1",
|
|
2693
|
+
thread_ts: "100.1",
|
|
2694
|
+
task_display_mode: "timeline",
|
|
2695
|
+
});
|
|
2696
|
+
expect(client.chat.stopStream).toHaveBeenCalled();
|
|
2697
|
+
});
|
|
2698
|
+
|
|
2699
|
+
test("delivers the final reply as a markdown_text chunk via chat.appendStream, and skips client.chat.postMessage() (STS2-2.1 AC #4)", async () => {
|
|
2700
|
+
createSlackApp();
|
|
2701
|
+
const client = makeMockClient();
|
|
2702
|
+
await capturedReactionAddedHandler?.({
|
|
2703
|
+
event: {
|
|
2704
|
+
reaction: "thumbsup",
|
|
2705
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
2706
|
+
item_user: "UBOT123",
|
|
2707
|
+
user: "U-DAN",
|
|
2708
|
+
},
|
|
2709
|
+
client,
|
|
2710
|
+
});
|
|
2711
|
+
|
|
2712
|
+
const markdownChunks = client.chat.appendStream.mock.calls
|
|
2713
|
+
.flatMap(
|
|
2714
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
2715
|
+
)
|
|
2716
|
+
.filter((chunk) => chunk.type === "markdown_text");
|
|
2717
|
+
expect(markdownChunks).toContainEqual({
|
|
2718
|
+
type: "markdown_text",
|
|
2719
|
+
text: "Claude response text",
|
|
2720
|
+
});
|
|
2721
|
+
// Delivered via the stream — postMessage() is not used for the real reply.
|
|
2722
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
2723
|
+
});
|
|
2724
|
+
|
|
2725
|
+
test("falls back to client.chat.postMessage() when chat.appendStream rejects (delivery failure never drops the reply) (STS2-2.1 AC #4)", async () => {
|
|
2726
|
+
createSlackApp();
|
|
2727
|
+
const client = makeMockClient();
|
|
2728
|
+
client.chat.appendStream.mockImplementation(async () => {
|
|
2729
|
+
throw new Error("streaming_mode_mismatch");
|
|
2730
|
+
});
|
|
2731
|
+
await capturedReactionAddedHandler?.({
|
|
2732
|
+
event: {
|
|
2733
|
+
reaction: "thumbsup",
|
|
2734
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
2735
|
+
item_user: "UBOT123",
|
|
2736
|
+
user: "U-DAN",
|
|
2737
|
+
},
|
|
2738
|
+
client,
|
|
2739
|
+
});
|
|
2740
|
+
expect(client.chat.postMessage).toHaveBeenCalledWith({
|
|
2741
|
+
channel: "D1",
|
|
2742
|
+
text: "[formatted] Claude response text",
|
|
2743
|
+
});
|
|
2744
|
+
});
|
|
2745
|
+
|
|
2746
|
+
test("marks the Thinking Steps card status:'error' when runClaude throws (STS2-4.1 AC #1)", async () => {
|
|
2747
|
+
createSlackApp();
|
|
2748
|
+
mockRunClaude.mockRejectedValueOnce(new Error("boom"));
|
|
2749
|
+
const client = makeMockClient();
|
|
2750
|
+
await capturedReactionAddedHandler?.({
|
|
2751
|
+
event: {
|
|
2752
|
+
reaction: "thumbsup",
|
|
2753
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
2754
|
+
item_user: "UBOT123",
|
|
2755
|
+
user: "U-DAN",
|
|
2756
|
+
},
|
|
2757
|
+
client,
|
|
2758
|
+
});
|
|
2759
|
+
|
|
2760
|
+
const taskUpdateChunks = client.chat.appendStream.mock.calls
|
|
2761
|
+
.flatMap(
|
|
2762
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
2763
|
+
)
|
|
2764
|
+
.filter((chunk) => chunk.type === "task_update");
|
|
2765
|
+
const errorChunks = taskUpdateChunks.filter(
|
|
2766
|
+
(chunk) => chunk.status === "error",
|
|
2767
|
+
);
|
|
2768
|
+
expect(errorChunks.length).toBeGreaterThan(0);
|
|
2769
|
+
|
|
2770
|
+
expect(client.chat.stopStream).toHaveBeenCalled();
|
|
2771
|
+
const completeChunks = taskUpdateChunks.filter(
|
|
2772
|
+
(chunk) => chunk.status === "complete",
|
|
2773
|
+
);
|
|
2774
|
+
expect(completeChunks.length).toBe(0);
|
|
2775
|
+
});
|
|
2776
|
+
|
|
2777
|
+
test("builds prompt containing emoji name and display name", async () => {
|
|
2778
|
+
mockResolveUserFn.mockResolvedValueOnce("Dan");
|
|
2779
|
+
await invokeReactionAdded({ reaction: "tada" });
|
|
2780
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
2781
|
+
expect(prompt).toContain(":tada:");
|
|
2782
|
+
expect(prompt).toContain("Dan");
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
test("ignores reaction on non-message item type", async () => {
|
|
2786
|
+
await invokeReactionAdded({
|
|
2787
|
+
item: { type: "file", channel: "D1", ts: "100.1" },
|
|
2788
|
+
});
|
|
2789
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
2790
|
+
});
|
|
2791
|
+
|
|
2792
|
+
test("ignores reaction not on bot's own message (item_user !== botUserId)", async () => {
|
|
2793
|
+
await invokeReactionAdded({ item_user: "U-OTHER-USER" });
|
|
2794
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
2795
|
+
});
|
|
2796
|
+
|
|
2797
|
+
test("ignores reaction in non-DM channel", async () => {
|
|
2798
|
+
await invokeReactionAdded({
|
|
2799
|
+
item: { type: "message", channel: "C-GENERAL", ts: "100.1" },
|
|
2800
|
+
});
|
|
2801
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2804
|
+
test("posts Claude response to DM via client.chat.postMessage when the stream is unavailable", async () => {
|
|
2805
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2806
|
+
result: "Logged your walk!",
|
|
2807
|
+
sessionId: "sess-2",
|
|
2808
|
+
});
|
|
2809
|
+
const { client } = await invokeReactionAdded({
|
|
2810
|
+
client: makeFallbackForcedClient(),
|
|
2811
|
+
});
|
|
2812
|
+
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
|
|
2813
|
+
const callArg = (
|
|
2814
|
+
client.chat.postMessage.mock.calls[0] as [Record<string, unknown>]
|
|
2815
|
+
)[0];
|
|
2816
|
+
expect(callArg.channel).toBe("D1");
|
|
2817
|
+
expect(callArg.text).toContain("Logged your walk!");
|
|
2818
|
+
});
|
|
2819
|
+
|
|
2820
|
+
test("streamIncomplete:true does not post — treated as a failed run, not a silent success (CSU-1.1 regression)", async () => {
|
|
2821
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2822
|
+
result: "",
|
|
2823
|
+
streamIncomplete: true,
|
|
2824
|
+
});
|
|
2825
|
+
const { client } = await invokeReactionAdded({});
|
|
2826
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
2827
|
+
});
|
|
2828
|
+
|
|
2829
|
+
test("skips post when Claude returns [silent]", async () => {
|
|
2830
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2831
|
+
result: "[silent]",
|
|
2832
|
+
sessionId: "sess-3",
|
|
2833
|
+
});
|
|
2834
|
+
const { client } = await invokeReactionAdded({});
|
|
2835
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
2836
|
+
});
|
|
2837
|
+
|
|
2838
|
+
test("ignores all reactions when botUserId is undefined", async () => {
|
|
2839
|
+
createSlackApp({ botUserId: undefined });
|
|
2840
|
+
const client = makeMockClient();
|
|
2841
|
+
await capturedReactionAddedHandler?.({
|
|
2842
|
+
event: {
|
|
2843
|
+
reaction: "thumbsup",
|
|
2844
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
2845
|
+
item_user: "UBOT123",
|
|
2846
|
+
user: "U-DAN",
|
|
2847
|
+
},
|
|
2848
|
+
client,
|
|
2849
|
+
});
|
|
2850
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
2851
|
+
});
|
|
2852
|
+
|
|
2853
|
+
test("[react:tada] in reaction response — calls reactions.add on posted message", async () => {
|
|
2854
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2855
|
+
result: "Logged! [react:tada]",
|
|
2856
|
+
sessionId: "sess-4",
|
|
2857
|
+
});
|
|
2858
|
+
const { client } = await invokeReactionAdded({
|
|
2859
|
+
client: makeFallbackForcedClient(),
|
|
2860
|
+
});
|
|
2861
|
+
expect(client.reactions.add).toHaveBeenCalledTimes(1);
|
|
2862
|
+
expect(client.reactions.add).toHaveBeenCalledWith({
|
|
2863
|
+
channel: "D1",
|
|
2864
|
+
timestamp: expect.any(String),
|
|
2865
|
+
name: "tada",
|
|
2866
|
+
});
|
|
2867
|
+
});
|
|
2868
|
+
|
|
2869
|
+
test("[react:emoji] not dispatched when post fails", async () => {
|
|
2870
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2871
|
+
result: "Logged! [react:tada]",
|
|
2872
|
+
sessionId: "sess-5",
|
|
2873
|
+
});
|
|
2874
|
+
createSlackApp();
|
|
2875
|
+
const client = makeMockClient();
|
|
2876
|
+
// Force the stream delivery to fail so the client.chat.postMessage
|
|
2877
|
+
// fallback path is genuinely exercised, then simulate that fallback
|
|
2878
|
+
// itself failing too (returns no ts).
|
|
2879
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
2880
|
+
(client.chat.postMessage as ReturnType<typeof mock>).mockRejectedValueOnce(
|
|
2881
|
+
new Error("post failed"),
|
|
2882
|
+
);
|
|
2883
|
+
await capturedReactionAddedHandler?.({
|
|
2884
|
+
event: {
|
|
2885
|
+
reaction: "thumbsup",
|
|
2886
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
2887
|
+
item_user: "UBOT123",
|
|
2888
|
+
user: "U-DAN",
|
|
2889
|
+
},
|
|
2890
|
+
client,
|
|
2891
|
+
});
|
|
2892
|
+
expect(client.reactions.add).not.toHaveBeenCalled();
|
|
2893
|
+
});
|
|
2894
|
+
});
|
|
2895
|
+
|
|
2896
|
+
// ─── invalid_blocks retry — formatter(cleaned) fallback ──────────────────────
|
|
2897
|
+
|
|
2898
|
+
describe("invalid_blocks retry — falls back to formatter(cleaned) when blocks.text is empty", () => {
|
|
2899
|
+
// A markdown table causes markdownToBlocks() to return non-null (blocks path).
|
|
2900
|
+
// The invalid_blocks error shape must match isInvalidBlocksError():
|
|
2901
|
+
// code === "slack_webapi_platform_error" && data?.error === "invalid_blocks"
|
|
2902
|
+
const TABLE_RESPONSE = "| Name | Value |\n|------|-------|\n| Foo | Bar |";
|
|
2903
|
+
|
|
2904
|
+
function makeInvalidBlocksError() {
|
|
2905
|
+
const err = Object.assign(new Error("invalid_blocks"), {
|
|
2906
|
+
code: "slack_webapi_platform_error",
|
|
2907
|
+
data: { error: "invalid_blocks" },
|
|
2908
|
+
});
|
|
2909
|
+
return err;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
beforeEach(() => {
|
|
2913
|
+
mockRunClaude.mockClear();
|
|
2914
|
+
mockMarkdownToSlack.mockClear();
|
|
2915
|
+
});
|
|
2916
|
+
|
|
2917
|
+
test("DM handler — retries with plain text when say throws invalid_blocks", async () => {
|
|
2918
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2919
|
+
result: TABLE_RESPONSE,
|
|
2920
|
+
sessionId: "sess-ib-1",
|
|
2921
|
+
});
|
|
2922
|
+
createSlackApp();
|
|
2923
|
+
const client = makeMockClient();
|
|
2924
|
+
// deliverContent() must fail so the handler falls back to say()/postMessage() —
|
|
2925
|
+
// this describe block specifically exercises that fallback path.
|
|
2926
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
2927
|
+
const say = mock(async (_args: unknown) => ({ ts: "r.1" }));
|
|
2928
|
+
say.mockRejectedValueOnce(makeInvalidBlocksError());
|
|
2929
|
+
|
|
2930
|
+
await capturedMessageHandler?.({
|
|
2931
|
+
message: {
|
|
2932
|
+
channel: "D1",
|
|
2933
|
+
ts: "1.1",
|
|
2934
|
+
text: "show table",
|
|
2935
|
+
channel_type: "im",
|
|
2936
|
+
},
|
|
2937
|
+
say,
|
|
2938
|
+
client,
|
|
2939
|
+
});
|
|
2940
|
+
|
|
2941
|
+
// First call threw invalid_blocks; second call is the retry
|
|
2942
|
+
expect(say).toHaveBeenCalledTimes(2);
|
|
2943
|
+
const retryArgs = say.mock.calls[1][0] as Record<string, unknown>;
|
|
2944
|
+
// Retry text must be non-empty (blocks.text || formatter(cleaned) must not be falsy)
|
|
2945
|
+
expect(typeof retryArgs.text).toBe("string");
|
|
2946
|
+
expect((retryArgs.text as string).length).toBeGreaterThan(0);
|
|
2947
|
+
expect(retryArgs.thread_ts).toBe("1.1");
|
|
2948
|
+
});
|
|
2949
|
+
|
|
2950
|
+
test("DM handler — retry text is non-empty even when blocks path is taken", async () => {
|
|
2951
|
+
// The fix guards against empty blocks.text on retry: `blocks.text || formatter(cleaned)`.
|
|
2952
|
+
// blocks.text comes from the real markdownToSlack (not the injected formatter), so
|
|
2953
|
+
// for any non-empty table response blocks.text will be non-empty and || short-circuits.
|
|
2954
|
+
// This test confirms the retry text is never empty — the invariant the fix enforces.
|
|
2955
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2956
|
+
result: TABLE_RESPONSE,
|
|
2957
|
+
sessionId: "sess-ib-2",
|
|
2958
|
+
});
|
|
2959
|
+
createSlackApp();
|
|
2960
|
+
const client = makeMockClient();
|
|
2961
|
+
// deliverContent() must fail so the handler falls back to say()/postMessage() —
|
|
2962
|
+
// this describe block specifically exercises that fallback path.
|
|
2963
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
2964
|
+
const say = mock(async (_args: unknown) => ({ ts: "r.2" }));
|
|
2965
|
+
say.mockRejectedValueOnce(makeInvalidBlocksError());
|
|
2966
|
+
|
|
2967
|
+
await capturedMessageHandler?.({
|
|
2968
|
+
message: {
|
|
2969
|
+
channel: "D1",
|
|
2970
|
+
ts: "2.2",
|
|
2971
|
+
text: "show table",
|
|
2972
|
+
channel_type: "im",
|
|
2973
|
+
},
|
|
2974
|
+
say,
|
|
2975
|
+
client,
|
|
2976
|
+
});
|
|
2977
|
+
|
|
2978
|
+
expect(say).toHaveBeenCalledTimes(2);
|
|
2979
|
+
const retryArgs = say.mock.calls[1][0] as Record<string, unknown>;
|
|
2980
|
+
// The || guard ensures retry text is always non-empty (blocks.text or formatter fallback)
|
|
2981
|
+
expect((retryArgs.text as string).length).toBeGreaterThan(0);
|
|
2982
|
+
});
|
|
2983
|
+
|
|
2984
|
+
test("app_mention handler — retries with plain text when say throws invalid_blocks", async () => {
|
|
2985
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
2986
|
+
result: TABLE_RESPONSE,
|
|
2987
|
+
sessionId: "sess-ib-3",
|
|
2988
|
+
});
|
|
2989
|
+
createSlackApp();
|
|
2990
|
+
const client = makeMockClient();
|
|
2991
|
+
// deliverContent() must fail so the handler falls back to say()/postMessage() —
|
|
2992
|
+
// this describe block specifically exercises that fallback path.
|
|
2993
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
2994
|
+
const say = mock(async (_args: unknown) => ({ ts: "r.3" }));
|
|
2995
|
+
say.mockRejectedValueOnce(makeInvalidBlocksError());
|
|
2996
|
+
|
|
2997
|
+
await capturedMentionHandler?.({
|
|
2998
|
+
event: {
|
|
2999
|
+
text: "<@UBOT> show table",
|
|
3000
|
+
channel: "C1",
|
|
3001
|
+
ts: "3.3",
|
|
3002
|
+
},
|
|
3003
|
+
say,
|
|
3004
|
+
client,
|
|
3005
|
+
});
|
|
3006
|
+
|
|
3007
|
+
expect(say).toHaveBeenCalledTimes(2);
|
|
3008
|
+
const retryArgs = say.mock.calls[1][0] as Record<string, unknown>;
|
|
3009
|
+
expect(typeof retryArgs.text).toBe("string");
|
|
3010
|
+
expect((retryArgs.text as string).length).toBeGreaterThan(0);
|
|
3011
|
+
expect(retryArgs.thread_ts).toBe("3.3");
|
|
3012
|
+
});
|
|
3013
|
+
|
|
3014
|
+
test("reaction_added handler — retries postMessage with plain text when invalid_blocks", async () => {
|
|
3015
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
3016
|
+
result: TABLE_RESPONSE,
|
|
3017
|
+
sessionId: "sess-ib-4",
|
|
3018
|
+
});
|
|
3019
|
+
createSlackApp();
|
|
3020
|
+
const client = makeMockClient();
|
|
3021
|
+
// deliverContent() must fail so the handler falls back to say()/postMessage() —
|
|
3022
|
+
// this describe block specifically exercises that fallback path.
|
|
3023
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
3024
|
+
// First postMessage throws invalid_blocks; second succeeds
|
|
3025
|
+
(client.chat.postMessage as ReturnType<typeof mock>).mockRejectedValueOnce(
|
|
3026
|
+
makeInvalidBlocksError(),
|
|
3027
|
+
);
|
|
3028
|
+
|
|
3029
|
+
await capturedReactionAddedHandler?.({
|
|
3030
|
+
event: {
|
|
3031
|
+
reaction: "thumbsup",
|
|
3032
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
3033
|
+
item_user: "UBOT123",
|
|
3034
|
+
user: "U-DAN",
|
|
3035
|
+
},
|
|
3036
|
+
client,
|
|
3037
|
+
});
|
|
3038
|
+
|
|
3039
|
+
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
|
|
3040
|
+
const retryArgs = (client.chat.postMessage as ReturnType<typeof mock>).mock
|
|
3041
|
+
.calls[1][0] as Record<string, unknown>;
|
|
3042
|
+
expect(typeof retryArgs.text).toBe("string");
|
|
3043
|
+
expect((retryArgs.text as string).length).toBeGreaterThan(0);
|
|
3044
|
+
expect(retryArgs.channel).toBe("D1");
|
|
3045
|
+
});
|
|
3046
|
+
|
|
3047
|
+
test("reaction_added handler — does not throw when the invalid_blocks retry itself fails", async () => {
|
|
3048
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
3049
|
+
result: TABLE_RESPONSE,
|
|
3050
|
+
sessionId: "sess-ib-4b",
|
|
3051
|
+
});
|
|
3052
|
+
createSlackApp();
|
|
3053
|
+
const client = makeMockClient();
|
|
3054
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
3055
|
+
// First postMessage throws invalid_blocks; the plain-text retry also fails.
|
|
3056
|
+
(client.chat.postMessage as ReturnType<typeof mock>)
|
|
3057
|
+
.mockRejectedValueOnce(makeInvalidBlocksError())
|
|
3058
|
+
.mockRejectedValueOnce(new Error("retry also failed"));
|
|
3059
|
+
|
|
3060
|
+
await expect(
|
|
3061
|
+
capturedReactionAddedHandler?.({
|
|
3062
|
+
event: {
|
|
3063
|
+
reaction: "thumbsup",
|
|
3064
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
3065
|
+
item_user: "UBOT123",
|
|
3066
|
+
user: "U-DAN",
|
|
3067
|
+
},
|
|
3068
|
+
client,
|
|
3069
|
+
}),
|
|
3070
|
+
).resolves.toBeUndefined();
|
|
3071
|
+
|
|
3072
|
+
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
|
|
3073
|
+
});
|
|
3074
|
+
|
|
3075
|
+
test("reaction_added handler — does not retry on a non-invalid_blocks postMessage failure", async () => {
|
|
3076
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
3077
|
+
result: TABLE_RESPONSE,
|
|
3078
|
+
sessionId: "sess-ib-4c",
|
|
3079
|
+
});
|
|
3080
|
+
createSlackApp();
|
|
3081
|
+
const client = makeMockClient();
|
|
3082
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
3083
|
+
// A genuine (non-invalid_blocks) postMessage failure on the blocks path
|
|
3084
|
+
// must NOT trigger the plain-text retry — only a single call.
|
|
3085
|
+
(client.chat.postMessage as ReturnType<typeof mock>).mockRejectedValueOnce(
|
|
3086
|
+
new Error("rate limited"),
|
|
3087
|
+
);
|
|
3088
|
+
|
|
3089
|
+
await expect(
|
|
3090
|
+
capturedReactionAddedHandler?.({
|
|
3091
|
+
event: {
|
|
3092
|
+
reaction: "thumbsup",
|
|
3093
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
3094
|
+
item_user: "UBOT123",
|
|
3095
|
+
user: "U-DAN",
|
|
3096
|
+
},
|
|
3097
|
+
client,
|
|
3098
|
+
}),
|
|
3099
|
+
).resolves.toBeUndefined();
|
|
3100
|
+
|
|
3101
|
+
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
|
|
3102
|
+
});
|
|
3103
|
+
|
|
3104
|
+
// ─── DI-based tests: blocks.text === '' → fallback to formatter(cleaned) ──
|
|
3105
|
+
|
|
3106
|
+
test("DM handler — uses formatter(cleaned) on retry when blocks.text is empty (DI)", async () => {
|
|
3107
|
+
// Inject a blocksConverter that returns { text: '', blocks: [...] } to directly
|
|
3108
|
+
// exercise the `blocks.text || formatter(cleaned)` right-hand side.
|
|
3109
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
3110
|
+
result: TABLE_RESPONSE,
|
|
3111
|
+
sessionId: "sess-ib-empty-dm",
|
|
3112
|
+
});
|
|
3113
|
+
createSlackApp({
|
|
3114
|
+
blocksConverter: (_text: string) =>
|
|
3115
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal test stub for SlackBlock
|
|
3116
|
+
({ text: "", blocks: [{ type: "section" }] }) as any,
|
|
3117
|
+
});
|
|
3118
|
+
const client = makeMockClient();
|
|
3119
|
+
// deliverContent() must fail so the handler falls back to say()/postMessage() —
|
|
3120
|
+
// this describe block specifically exercises that fallback path.
|
|
3121
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
3122
|
+
const say = mock(async (_args: unknown) => ({ ts: "r.empty.1" }));
|
|
3123
|
+
say.mockRejectedValueOnce(makeInvalidBlocksError());
|
|
3124
|
+
|
|
3125
|
+
await capturedMessageHandler?.({
|
|
3126
|
+
message: {
|
|
3127
|
+
channel: "D1",
|
|
3128
|
+
ts: "10.1",
|
|
3129
|
+
text: "show table",
|
|
3130
|
+
channel_type: "im",
|
|
3131
|
+
},
|
|
3132
|
+
say,
|
|
3133
|
+
client,
|
|
3134
|
+
});
|
|
3135
|
+
|
|
3136
|
+
expect(say).toHaveBeenCalledTimes(2);
|
|
3137
|
+
const retryArgs = say.mock.calls[1][0] as Record<string, unknown>;
|
|
3138
|
+
// blocks.text is '' → fallback to formatter(cleaned) → '[formatted] ...'
|
|
3139
|
+
expect(retryArgs.text as string).toMatch(/^\[formatted\]/);
|
|
3140
|
+
expect((retryArgs.text as string).length).toBeGreaterThan(0);
|
|
3141
|
+
expect(retryArgs.thread_ts).toBe("10.1");
|
|
3142
|
+
});
|
|
3143
|
+
|
|
3144
|
+
test("app_mention handler — uses formatter(cleaned) on retry when blocks.text is empty (DI)", async () => {
|
|
3145
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
3146
|
+
result: TABLE_RESPONSE,
|
|
3147
|
+
sessionId: "sess-ib-empty-mention",
|
|
3148
|
+
});
|
|
3149
|
+
createSlackApp({
|
|
3150
|
+
blocksConverter: (_text: string) =>
|
|
3151
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal test stub for SlackBlock
|
|
3152
|
+
({ text: "", blocks: [{ type: "section" }] }) as any,
|
|
3153
|
+
});
|
|
3154
|
+
const client = makeMockClient();
|
|
3155
|
+
// deliverContent() must fail so the handler falls back to say()/postMessage() —
|
|
3156
|
+
// this describe block specifically exercises that fallback path.
|
|
3157
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
3158
|
+
const say = mock(async (_args: unknown) => ({ ts: "r.empty.2" }));
|
|
3159
|
+
say.mockRejectedValueOnce(makeInvalidBlocksError());
|
|
3160
|
+
|
|
3161
|
+
await capturedMentionHandler?.({
|
|
3162
|
+
event: {
|
|
3163
|
+
text: "<@UBOT> show table",
|
|
3164
|
+
channel: "C1",
|
|
3165
|
+
ts: "20.1",
|
|
3166
|
+
},
|
|
3167
|
+
say,
|
|
3168
|
+
client,
|
|
3169
|
+
});
|
|
3170
|
+
|
|
3171
|
+
expect(say).toHaveBeenCalledTimes(2);
|
|
3172
|
+
const retryArgs = say.mock.calls[1][0] as Record<string, unknown>;
|
|
3173
|
+
expect(retryArgs.text as string).toMatch(/^\[formatted\]/);
|
|
3174
|
+
expect((retryArgs.text as string).length).toBeGreaterThan(0);
|
|
3175
|
+
expect(retryArgs.thread_ts).toBe("20.1");
|
|
3176
|
+
});
|
|
3177
|
+
|
|
3178
|
+
test("reaction_added handler — uses formatter(cleaned) on retry when blocks.text is empty (DI)", async () => {
|
|
3179
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
3180
|
+
result: TABLE_RESPONSE,
|
|
3181
|
+
sessionId: "sess-ib-empty-reaction",
|
|
3182
|
+
});
|
|
3183
|
+
createSlackApp({
|
|
3184
|
+
blocksConverter: (_text: string) =>
|
|
3185
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal test stub for SlackBlock
|
|
3186
|
+
({ text: "", blocks: [{ type: "section" }] }) as any,
|
|
3187
|
+
});
|
|
3188
|
+
const client = makeMockClient();
|
|
3189
|
+
// deliverContent() must fail so the handler falls back to say()/postMessage() —
|
|
3190
|
+
// this describe block specifically exercises that fallback path.
|
|
3191
|
+
client.chat.appendStream.mockRejectedValue(new Error("stream unavailable"));
|
|
3192
|
+
(client.chat.postMessage as ReturnType<typeof mock>).mockRejectedValueOnce(
|
|
3193
|
+
makeInvalidBlocksError(),
|
|
3194
|
+
);
|
|
3195
|
+
|
|
3196
|
+
await capturedReactionAddedHandler?.({
|
|
3197
|
+
event: {
|
|
3198
|
+
reaction: "thumbsup",
|
|
3199
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
3200
|
+
item_user: "UBOT123",
|
|
3201
|
+
user: "U-DAN",
|
|
3202
|
+
},
|
|
3203
|
+
client,
|
|
3204
|
+
});
|
|
3205
|
+
|
|
3206
|
+
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
|
|
3207
|
+
const retryArgs = (client.chat.postMessage as ReturnType<typeof mock>).mock
|
|
3208
|
+
.calls[1][0] as Record<string, unknown>;
|
|
3209
|
+
expect(retryArgs.text as string).toMatch(/^\[formatted\]/);
|
|
3210
|
+
expect((retryArgs.text as string).length).toBeGreaterThan(0);
|
|
3211
|
+
expect(retryArgs.channel).toBe("D1");
|
|
3212
|
+
});
|
|
3213
|
+
});
|
|
3214
|
+
|
|
3215
|
+
describe("formatRunErrorForSlack", () => {
|
|
3216
|
+
test("monthly usage limit (429 + 'usage limit') gets dedicated message", () => {
|
|
3217
|
+
const err = new ClaudeRunError(
|
|
3218
|
+
"claude exited 1: api_error_status=429 You've hit your org's monthly usage limit",
|
|
3219
|
+
429,
|
|
3220
|
+
"You've hit your org's monthly usage limit",
|
|
3221
|
+
undefined,
|
|
3222
|
+
);
|
|
3223
|
+
const out = formatRunErrorForSlack(err);
|
|
3224
|
+
expect(out).toContain("monthly Claude usage limit");
|
|
3225
|
+
expect(out).toContain("agent owner");
|
|
3226
|
+
expect(out).toContain("You've hit your org's monthly usage limit");
|
|
3227
|
+
});
|
|
3228
|
+
|
|
3229
|
+
test("generic 429 gets rate-limit message", () => {
|
|
3230
|
+
const err = new ClaudeRunError(
|
|
3231
|
+
"rate limited",
|
|
3232
|
+
429,
|
|
3233
|
+
"rate_limit_error",
|
|
3234
|
+
undefined,
|
|
3235
|
+
);
|
|
3236
|
+
const out = formatRunErrorForSlack(err);
|
|
3237
|
+
expect(out).toContain("Rate-limited");
|
|
3238
|
+
});
|
|
3239
|
+
|
|
3240
|
+
test("529 gets overload message + status link", () => {
|
|
3241
|
+
const err = new ClaudeRunError("overloaded", 529, "Overloaded", undefined);
|
|
3242
|
+
const out = formatRunErrorForSlack(err);
|
|
3243
|
+
expect(out).toContain("overloaded");
|
|
3244
|
+
expect(out).toContain("status.claude.com");
|
|
3245
|
+
});
|
|
3246
|
+
|
|
3247
|
+
test("500 gets transient hiccup message", () => {
|
|
3248
|
+
const err = new ClaudeRunError(
|
|
3249
|
+
"5xx",
|
|
3250
|
+
500,
|
|
3251
|
+
"API Error: 500 Internal server error.",
|
|
3252
|
+
undefined,
|
|
3253
|
+
);
|
|
3254
|
+
const out = formatRunErrorForSlack(err);
|
|
3255
|
+
expect(out).toContain("500");
|
|
3256
|
+
expect(out).toContain("status.claude.com");
|
|
3257
|
+
});
|
|
3258
|
+
|
|
3259
|
+
test("401 gets auth-failure message", () => {
|
|
3260
|
+
const err = new ClaudeRunError("auth", 401, "invalid api key", undefined);
|
|
3261
|
+
const out = formatRunErrorForSlack(err);
|
|
3262
|
+
expect(out).toContain("Auth failure");
|
|
3263
|
+
});
|
|
3264
|
+
|
|
3265
|
+
test("unknown ClaudeRunError includes detail and stack", () => {
|
|
3266
|
+
const err = new ClaudeRunError("weird", 418, "I'm a teapot", undefined);
|
|
3267
|
+
const out = formatRunErrorForSlack(err);
|
|
3268
|
+
expect(out).toContain("418");
|
|
3269
|
+
expect(out).toContain("I'm a teapot");
|
|
3270
|
+
expect(out).toMatch(/```[\s\S]*ClaudeRunError[\s\S]*```/);
|
|
3271
|
+
});
|
|
3272
|
+
|
|
3273
|
+
test("plain Error falls back to generic + detail with class name", () => {
|
|
3274
|
+
const out = formatRunErrorForSlack(new TypeError("spawn EPIPE"));
|
|
3275
|
+
expect(out).toContain("Something went wrong");
|
|
3276
|
+
expect(out).toContain("TypeError: spawn EPIPE");
|
|
3277
|
+
});
|
|
3278
|
+
|
|
3279
|
+
test("includes stack trace for Error instances", () => {
|
|
3280
|
+
const out = formatRunErrorForSlack(new Error("boom"));
|
|
3281
|
+
expect(out).toMatch(/```[\s\S]*Error: boom[\s\S]*at[\s\S]*```/);
|
|
3282
|
+
});
|
|
3283
|
+
|
|
3284
|
+
test("known ClaudeRunError omits stack trace", () => {
|
|
3285
|
+
const out = formatRunErrorForSlack(
|
|
3286
|
+
new ClaudeRunError("msg", 429, "rate_limit_error", undefined),
|
|
3287
|
+
);
|
|
3288
|
+
expect(out).not.toContain("```");
|
|
3289
|
+
});
|
|
3290
|
+
|
|
3291
|
+
test("non-Error values produce no stack block", () => {
|
|
3292
|
+
const out = formatRunErrorForSlack("just a string");
|
|
3293
|
+
expect(out).toContain("Something went wrong");
|
|
3294
|
+
expect(out).toContain("just a string");
|
|
3295
|
+
expect(out).not.toContain("```");
|
|
3296
|
+
});
|
|
3297
|
+
|
|
3298
|
+
test("ClaudeTimeoutError includes timeout duration and retry prompt", () => {
|
|
3299
|
+
const err = new ClaudeTimeoutError(30 * 60 * 1000, "ceiling");
|
|
3300
|
+
const out = formatRunErrorForSlack(err);
|
|
3301
|
+
expect(out).toContain("30 minutes");
|
|
3302
|
+
expect(out).toContain("retry");
|
|
3303
|
+
expect(out).not.toContain("```");
|
|
3304
|
+
});
|
|
3305
|
+
|
|
3306
|
+
test("ClaudeRunError with undefined sessionId handled gracefully", () => {
|
|
3307
|
+
const err = new ClaudeRunError(
|
|
3308
|
+
"msg",
|
|
3309
|
+
500,
|
|
3310
|
+
"Internal server error",
|
|
3311
|
+
undefined,
|
|
3312
|
+
);
|
|
3313
|
+
const out = formatRunErrorForSlack(err);
|
|
3314
|
+
expect(out).toContain("500");
|
|
3315
|
+
expect(() => formatRunErrorForSlack(err)).not.toThrow();
|
|
3316
|
+
});
|
|
3317
|
+
});
|
|
3318
|
+
|
|
3319
|
+
// ─── Thread history on first @mention ────────────────────────────────────────
|
|
3320
|
+
|
|
3321
|
+
describe("app_mention handler — thread history on first mention", () => {
|
|
3322
|
+
beforeEach(() => {
|
|
3323
|
+
mockRunClaude.mockClear();
|
|
3324
|
+
mockResolveUserFn.mockClear();
|
|
3325
|
+
});
|
|
3326
|
+
|
|
3327
|
+
test("first @mention in thread: fetches history and prepends to prompt", async () => {
|
|
3328
|
+
const mockRepliesFn = mock(
|
|
3329
|
+
async (_client: unknown, _channel: string, _ts: string) => ({
|
|
3330
|
+
messages: [
|
|
3331
|
+
{ user: "U1", text: "Hey team, anyone know the status?" },
|
|
3332
|
+
{ user: "U2", text: "I think it's done" },
|
|
3333
|
+
],
|
|
3334
|
+
}),
|
|
3335
|
+
);
|
|
3336
|
+
// resolveUserFn returns different names for different users
|
|
3337
|
+
mockResolveUserFn.mockImplementation(
|
|
3338
|
+
async (userId: string, _client: unknown) => {
|
|
3339
|
+
if (userId === "U1") return "Alice";
|
|
3340
|
+
if (userId === "U2") return "Bob";
|
|
3341
|
+
return userId;
|
|
3342
|
+
},
|
|
3343
|
+
);
|
|
3344
|
+
|
|
3345
|
+
createSlackApp({ conversationsRepliesFn: mockRepliesFn });
|
|
3346
|
+
|
|
3347
|
+
const client = makeMockClient();
|
|
3348
|
+
const say = makeSay();
|
|
3349
|
+
await capturedMentionHandler?.({
|
|
3350
|
+
event: {
|
|
3351
|
+
text: "@bot help",
|
|
3352
|
+
channel: "C123",
|
|
3353
|
+
ts: "1001.0",
|
|
3354
|
+
thread_ts: "1000.0",
|
|
3355
|
+
user: "U1",
|
|
3356
|
+
},
|
|
3357
|
+
say,
|
|
3358
|
+
client,
|
|
3359
|
+
});
|
|
3360
|
+
|
|
3361
|
+
expect(mockRepliesFn).toHaveBeenCalledTimes(1);
|
|
3362
|
+
expect(mockRepliesFn).toHaveBeenCalledWith(client, "C123", "1000.0");
|
|
3363
|
+
|
|
3364
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
3365
|
+
expect(prompt).toContain("[Thread context]");
|
|
3366
|
+
expect(prompt).toContain("[end thread context]");
|
|
3367
|
+
expect(prompt).toContain("[Alice]: Hey team, anyone know the status?");
|
|
3368
|
+
expect(prompt).toContain("[Bob]: I think it's done");
|
|
3369
|
+
expect(prompt).toContain("@bot help");
|
|
3370
|
+
});
|
|
3371
|
+
|
|
3372
|
+
test("first @mention without thread_ts: no history fetch", async () => {
|
|
3373
|
+
const mockRepliesFn = mock(async () => ({ messages: [] }));
|
|
3374
|
+
createSlackApp({ conversationsRepliesFn: mockRepliesFn });
|
|
3375
|
+
|
|
3376
|
+
const client = makeMockClient();
|
|
3377
|
+
const say = makeSay();
|
|
3378
|
+
await capturedMentionHandler?.({
|
|
3379
|
+
event: {
|
|
3380
|
+
text: "@bot help",
|
|
3381
|
+
channel: "C123",
|
|
3382
|
+
ts: "1001.0",
|
|
3383
|
+
// no thread_ts
|
|
3384
|
+
},
|
|
3385
|
+
say,
|
|
3386
|
+
client,
|
|
3387
|
+
});
|
|
3388
|
+
|
|
3389
|
+
expect(mockRepliesFn).not.toHaveBeenCalled();
|
|
3390
|
+
});
|
|
3391
|
+
|
|
3392
|
+
test("subsequent @mention when session exists: no history fetch", async () => {
|
|
3393
|
+
const mockRepliesFn = mock(async () => ({ messages: [] }));
|
|
3394
|
+
// Provide a getSessionFn that returns a session — bot already knows the thread
|
|
3395
|
+
createSlackApp({
|
|
3396
|
+
conversationsRepliesFn: mockRepliesFn,
|
|
3397
|
+
getSessionFn: mock(async () => "sess-123"),
|
|
3398
|
+
});
|
|
3399
|
+
|
|
3400
|
+
const client = makeMockClient();
|
|
3401
|
+
const say = makeSay();
|
|
3402
|
+
await capturedMentionHandler?.({
|
|
3403
|
+
event: {
|
|
3404
|
+
text: "@bot help again",
|
|
3405
|
+
channel: "C123",
|
|
3406
|
+
ts: "1002.0",
|
|
3407
|
+
thread_ts: "1000.0",
|
|
3408
|
+
},
|
|
3409
|
+
say,
|
|
3410
|
+
client,
|
|
3411
|
+
});
|
|
3412
|
+
|
|
3413
|
+
expect(mockRepliesFn).not.toHaveBeenCalled();
|
|
3414
|
+
});
|
|
3415
|
+
|
|
3416
|
+
test("conversations.replies failure: proceeds without history, no crash", async () => {
|
|
3417
|
+
const mockRepliesFn = mock(async () => {
|
|
3418
|
+
throw new Error("network failure");
|
|
3419
|
+
});
|
|
3420
|
+
createSlackApp({ conversationsRepliesFn: mockRepliesFn });
|
|
3421
|
+
|
|
3422
|
+
const client = makeMockClient();
|
|
3423
|
+
const say = makeSay();
|
|
3424
|
+
await capturedMentionHandler?.({
|
|
3425
|
+
event: {
|
|
3426
|
+
text: "@bot help",
|
|
3427
|
+
channel: "C123",
|
|
3428
|
+
ts: "1001.0",
|
|
3429
|
+
thread_ts: "1000.0",
|
|
3430
|
+
},
|
|
3431
|
+
say,
|
|
3432
|
+
client,
|
|
3433
|
+
});
|
|
3434
|
+
|
|
3435
|
+
// runClaude must still be called — no crash
|
|
3436
|
+
expect(mockRunClaude).toHaveBeenCalledTimes(1);
|
|
3437
|
+
// Prompt should not contain thread context markers (graceful fallback)
|
|
3438
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
3439
|
+
expect(prompt).not.toContain("[Thread context]");
|
|
3440
|
+
});
|
|
3441
|
+
|
|
3442
|
+
test("bot messages are excluded from thread history", async () => {
|
|
3443
|
+
const mockRepliesFn = mock(
|
|
3444
|
+
async (_client: unknown, _channel: string, _ts: string) => ({
|
|
3445
|
+
messages: [
|
|
3446
|
+
{ user: "U1", text: "Human message" },
|
|
3447
|
+
{ user: "UBOT123", text: "Bot reply — should be excluded" },
|
|
3448
|
+
{ user: "U2", text: "Another human" },
|
|
3449
|
+
],
|
|
3450
|
+
}),
|
|
3451
|
+
);
|
|
3452
|
+
createSlackApp({
|
|
3453
|
+
conversationsRepliesFn: mockRepliesFn,
|
|
3454
|
+
botUserId: "UBOT123",
|
|
3455
|
+
});
|
|
3456
|
+
|
|
3457
|
+
const client = makeMockClient();
|
|
3458
|
+
const say = makeSay();
|
|
3459
|
+
await capturedMentionHandler?.({
|
|
3460
|
+
event: {
|
|
3461
|
+
text: "@bot help",
|
|
3462
|
+
channel: "C123",
|
|
3463
|
+
ts: "1001.0",
|
|
3464
|
+
thread_ts: "1000.0",
|
|
3465
|
+
},
|
|
3466
|
+
say,
|
|
3467
|
+
client,
|
|
3468
|
+
});
|
|
3469
|
+
|
|
3470
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
3471
|
+
expect(prompt).not.toContain("Bot reply — should be excluded");
|
|
3472
|
+
expect(prompt).toContain("Human message");
|
|
3473
|
+
expect(prompt).toContain("Another human");
|
|
3474
|
+
});
|
|
3475
|
+
|
|
3476
|
+
test("messages with no text are excluded from thread history", async () => {
|
|
3477
|
+
const mockRepliesFn = mock(
|
|
3478
|
+
async (_client: unknown, _channel: string, _ts: string) => ({
|
|
3479
|
+
messages: [
|
|
3480
|
+
{ user: "U1", text: "Has text" },
|
|
3481
|
+
{ user: "U2" /* no text field */ },
|
|
3482
|
+
{ user: "U3", text: "" /* empty text */ },
|
|
3483
|
+
],
|
|
3484
|
+
}),
|
|
3485
|
+
);
|
|
3486
|
+
createSlackApp({ conversationsRepliesFn: mockRepliesFn });
|
|
3487
|
+
|
|
3488
|
+
const client = makeMockClient();
|
|
3489
|
+
const say = makeSay();
|
|
3490
|
+
await capturedMentionHandler?.({
|
|
3491
|
+
event: {
|
|
3492
|
+
text: "@bot help",
|
|
3493
|
+
channel: "C123",
|
|
3494
|
+
ts: "1001.0",
|
|
3495
|
+
thread_ts: "1000.0",
|
|
3496
|
+
},
|
|
3497
|
+
say,
|
|
3498
|
+
client,
|
|
3499
|
+
});
|
|
3500
|
+
|
|
3501
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
3502
|
+
expect(prompt).toContain("Has text");
|
|
3503
|
+
// U2 and U3 should not appear
|
|
3504
|
+
const lines = prompt.split("\n").filter((l) => l.startsWith("["));
|
|
3505
|
+
// Only U1's message and the @mention itself (after [end thread context])
|
|
3506
|
+
const historyLines = lines.filter(
|
|
3507
|
+
(l) =>
|
|
3508
|
+
!l.startsWith("[Thread context]") &&
|
|
3509
|
+
!l.startsWith("[end thread context]"),
|
|
3510
|
+
);
|
|
3511
|
+
const u2u3Lines = historyLines.filter(
|
|
3512
|
+
(l) => !l.includes("Has text") && !l.includes("@bot help"),
|
|
3513
|
+
);
|
|
3514
|
+
expect(u2u3Lines.length).toBe(0);
|
|
3515
|
+
});
|
|
3516
|
+
|
|
3517
|
+
test("bot messages are included in thread history when botUserId is undefined", async () => {
|
|
3518
|
+
// When botUserId is unknown, the filter (!botUserId || m.user !== botUserId)
|
|
3519
|
+
// short-circuits to true — no messages are excluded by user ID.
|
|
3520
|
+
// This is the safe behavior: don't accidentally filter human messages just
|
|
3521
|
+
// because we can't identify the bot.
|
|
3522
|
+
const mockRepliesFn = mock(
|
|
3523
|
+
async (_client: unknown, _channel: string, _ts: string) => ({
|
|
3524
|
+
messages: [
|
|
3525
|
+
{
|
|
3526
|
+
user: "UBOT_NO_ID",
|
|
3527
|
+
text: "Bot message without botUserId",
|
|
3528
|
+
ts: "999.0",
|
|
3529
|
+
},
|
|
3530
|
+
{ user: "U1", text: "Human message", ts: "998.0" },
|
|
3531
|
+
],
|
|
3532
|
+
}),
|
|
3533
|
+
);
|
|
3534
|
+
createSlackApp({
|
|
3535
|
+
conversationsRepliesFn: mockRepliesFn,
|
|
3536
|
+
botUserId: undefined,
|
|
3537
|
+
});
|
|
3538
|
+
|
|
3539
|
+
const client = makeMockClient();
|
|
3540
|
+
const say = makeSay();
|
|
3541
|
+
await capturedMentionHandler?.({
|
|
3542
|
+
event: {
|
|
3543
|
+
text: "@bot help",
|
|
3544
|
+
channel: "C123",
|
|
3545
|
+
ts: "1001.0",
|
|
3546
|
+
thread_ts: "1000.0",
|
|
3547
|
+
},
|
|
3548
|
+
say,
|
|
3549
|
+
client,
|
|
3550
|
+
});
|
|
3551
|
+
|
|
3552
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
3553
|
+
// Both messages should appear — no bot filtering when botUserId is undefined
|
|
3554
|
+
expect(prompt).toContain("Bot message without botUserId");
|
|
3555
|
+
expect(prompt).toContain("Human message");
|
|
3556
|
+
});
|
|
3557
|
+
|
|
3558
|
+
test("triggering @mention is not duplicated in thread history", async () => {
|
|
3559
|
+
// conversations.replies returns all thread messages including the @mention
|
|
3560
|
+
// itself (at event.ts). The filter must exclude it so Claude doesn't see
|
|
3561
|
+
// the request twice — once in [Thread context] and once as the prompt.
|
|
3562
|
+
const mockRepliesFn = mock(
|
|
3563
|
+
async (_client: unknown, _channel: string, _ts: string) => ({
|
|
3564
|
+
messages: [
|
|
3565
|
+
{ user: "U1", text: "Prior message in thread", ts: "999.0" },
|
|
3566
|
+
{ user: "U2", text: "@bot help", ts: "1001.0" }, // same ts as event
|
|
3567
|
+
],
|
|
3568
|
+
}),
|
|
3569
|
+
);
|
|
3570
|
+
createSlackApp({ conversationsRepliesFn: mockRepliesFn });
|
|
3571
|
+
|
|
3572
|
+
const client = makeMockClient();
|
|
3573
|
+
const say = makeSay();
|
|
3574
|
+
await capturedMentionHandler?.({
|
|
3575
|
+
event: {
|
|
3576
|
+
text: "@bot help",
|
|
3577
|
+
channel: "C123",
|
|
3578
|
+
ts: "1001.0",
|
|
3579
|
+
thread_ts: "1000.0",
|
|
3580
|
+
user: "U2",
|
|
3581
|
+
},
|
|
3582
|
+
say,
|
|
3583
|
+
client,
|
|
3584
|
+
});
|
|
3585
|
+
|
|
3586
|
+
const prompt = mockRunClaude.mock.calls[0][0] as string;
|
|
3587
|
+
// "@bot help" should appear exactly once (as the current prompt), not in [Thread context]
|
|
3588
|
+
expect(prompt).toContain("Prior message in thread");
|
|
3589
|
+
const threadContextMatch = prompt.match(
|
|
3590
|
+
/\[Thread context\]([\s\S]*?)\[end thread context\]/,
|
|
3591
|
+
);
|
|
3592
|
+
expect(threadContextMatch).not.toBeNull();
|
|
3593
|
+
// The triggering message must not be inside [Thread context]
|
|
3594
|
+
expect(threadContextMatch?.[1]).not.toContain("@bot help");
|
|
3595
|
+
});
|
|
3596
|
+
});
|
|
3597
|
+
|
|
3598
|
+
// ─── dispatchMarkers (direct) ─────────────────────────────────────────────────
|
|
3599
|
+
//
|
|
3600
|
+
// These tests call dispatchMarkers directly (not through a handler) to cover
|
|
3601
|
+
// the full marker surface at the function boundary.
|
|
3602
|
+
|
|
3603
|
+
describe("dispatchMarkers — direct", () => {
|
|
3604
|
+
// biome-ignore lint/suspicious/noExplicitAny: mock Slack client
|
|
3605
|
+
let client: any;
|
|
3606
|
+
|
|
3607
|
+
beforeEach(() => {
|
|
3608
|
+
client = {
|
|
3609
|
+
reactions: { add: mock(() => Promise.resolve()) },
|
|
3610
|
+
files: { uploadV2: mock(() => Promise.resolve()) },
|
|
3611
|
+
chat: { postMessage: mock(() => Promise.resolve({ ts: "999.000" })) },
|
|
3612
|
+
};
|
|
3613
|
+
});
|
|
3614
|
+
|
|
3615
|
+
test("adds react emoji for react markers", async () => {
|
|
3616
|
+
await dispatchMarkers([{ type: "react", emojis: ["thumbsup"] }], {
|
|
3617
|
+
client,
|
|
3618
|
+
channel: "C123",
|
|
3619
|
+
postedTs: "1.0",
|
|
3620
|
+
});
|
|
3621
|
+
|
|
3622
|
+
expect(client.reactions.add).toHaveBeenCalledWith({
|
|
3623
|
+
channel: "C123",
|
|
3624
|
+
timestamp: "1.0",
|
|
3625
|
+
name: "thumbsup",
|
|
3626
|
+
});
|
|
3627
|
+
});
|
|
3628
|
+
|
|
3629
|
+
test("silent marker is skipped gracefully", async () => {
|
|
3630
|
+
await dispatchMarkers([{ type: "silent" }], { client, channel: "C123" });
|
|
3631
|
+
expect(client.reactions.add).not.toHaveBeenCalled();
|
|
3632
|
+
});
|
|
3633
|
+
|
|
3634
|
+
test("upload marker — skips when file does not exist", async () => {
|
|
3635
|
+
await dispatchMarkers([{ type: "upload", path: "/nonexistent/file.txt" }], {
|
|
3636
|
+
client,
|
|
3637
|
+
channel: "C123",
|
|
3638
|
+
});
|
|
3639
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
3640
|
+
});
|
|
3641
|
+
|
|
3642
|
+
test("upload marker — uploads when file exists", async () => {
|
|
3643
|
+
const tmpPath = join(tmpdir(), `test-dispatch-upload-${Date.now()}.txt`);
|
|
3644
|
+
writeFileSync(tmpPath, "dispatch upload content");
|
|
3645
|
+
|
|
3646
|
+
await dispatchMarkers([{ type: "upload", path: tmpPath }], {
|
|
3647
|
+
client,
|
|
3648
|
+
channel: "C456",
|
|
3649
|
+
});
|
|
3650
|
+
|
|
3651
|
+
expect(client.files.uploadV2).toHaveBeenCalledTimes(1);
|
|
3652
|
+
const uploadArgs = (
|
|
3653
|
+
client.files.uploadV2.mock.calls[0] as [Record<string, unknown>]
|
|
3654
|
+
)[0];
|
|
3655
|
+
expect(uploadArgs.channel_id).toBe("C456");
|
|
3656
|
+
expect(uploadArgs.filename).toBe(tmpPath.split("/").pop());
|
|
3657
|
+
unlinkSync(tmpPath);
|
|
3658
|
+
});
|
|
3659
|
+
|
|
3660
|
+
test("speak marker — calls synthesizeSpeechFn and uploads result", async () => {
|
|
3661
|
+
const outPath = join(tmpdir(), `test-dispatch-speak-${Date.now()}.mp3`);
|
|
3662
|
+
writeFileSync(outPath, Buffer.from("audio data"));
|
|
3663
|
+
const mockSynthesize = mock(async () => outPath);
|
|
3664
|
+
|
|
3665
|
+
await dispatchMarkers([{ type: "speak", text: "Hello there" }], {
|
|
3666
|
+
client,
|
|
3667
|
+
channel: "D1",
|
|
3668
|
+
synthesizeSpeechFn: mockSynthesize,
|
|
3669
|
+
voiceConfig: {},
|
|
3670
|
+
});
|
|
3671
|
+
|
|
3672
|
+
expect(mockSynthesize).toHaveBeenCalledWith("Hello there", {});
|
|
3673
|
+
expect(client.files.uploadV2).toHaveBeenCalledTimes(1);
|
|
3674
|
+
unlinkSync(outPath);
|
|
3675
|
+
});
|
|
3676
|
+
|
|
3677
|
+
test("speak marker — posts text fallback when synthesis returns null", async () => {
|
|
3678
|
+
const mockSynthesize = mock(async () => null);
|
|
3679
|
+
|
|
3680
|
+
await dispatchMarkers([{ type: "speak", text: "Hello" }], {
|
|
3681
|
+
client,
|
|
3682
|
+
channel: "D1",
|
|
3683
|
+
synthesizeSpeechFn: mockSynthesize,
|
|
3684
|
+
voiceConfig: {},
|
|
3685
|
+
});
|
|
3686
|
+
|
|
3687
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
3688
|
+
expect(client.chat.postMessage).toHaveBeenCalledWith(
|
|
3689
|
+
expect.objectContaining({ channel: "D1", text: "Hello" }),
|
|
3690
|
+
);
|
|
3691
|
+
});
|
|
3692
|
+
|
|
3693
|
+
test("speak marker — posts text fallback when synthesizeSpeechFn throws", async () => {
|
|
3694
|
+
const throwingSynthesize = mock(async () => {
|
|
3695
|
+
throw new Error("synthesis blew up");
|
|
3696
|
+
});
|
|
3697
|
+
|
|
3698
|
+
await dispatchMarkers([{ type: "speak", text: "Hello" }], {
|
|
3699
|
+
client,
|
|
3700
|
+
channel: "D1",
|
|
3701
|
+
synthesizeSpeechFn: throwingSynthesize,
|
|
3702
|
+
voiceConfig: {},
|
|
3703
|
+
});
|
|
3704
|
+
|
|
3705
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
3706
|
+
expect(client.chat.postMessage).toHaveBeenCalledWith(
|
|
3707
|
+
expect.objectContaining({ channel: "D1", text: "Hello" }),
|
|
3708
|
+
);
|
|
3709
|
+
});
|
|
3710
|
+
|
|
3711
|
+
test("speak marker — skips text fallback when it duplicates the already-posted reply", async () => {
|
|
3712
|
+
const mockSynthesize = mock(async () => null);
|
|
3713
|
+
|
|
3714
|
+
await dispatchMarkers([{ type: "speak", text: "Here is the answer" }], {
|
|
3715
|
+
client,
|
|
3716
|
+
channel: "D1",
|
|
3717
|
+
synthesizeSpeechFn: mockSynthesize,
|
|
3718
|
+
voiceConfig: {},
|
|
3719
|
+
postedText: "Here is the answer.",
|
|
3720
|
+
});
|
|
3721
|
+
|
|
3722
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
3723
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
3724
|
+
});
|
|
3725
|
+
|
|
3726
|
+
test("speak marker — still posts text fallback when marker text differs from the already-posted reply", async () => {
|
|
3727
|
+
const mockSynthesize = mock(async () => null);
|
|
3728
|
+
|
|
3729
|
+
await dispatchMarkers([{ type: "speak", text: "Hello" }], {
|
|
3730
|
+
client,
|
|
3731
|
+
channel: "D1",
|
|
3732
|
+
synthesizeSpeechFn: mockSynthesize,
|
|
3733
|
+
voiceConfig: {},
|
|
3734
|
+
postedText: "Something completely different was posted here.",
|
|
3735
|
+
});
|
|
3736
|
+
|
|
3737
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
3738
|
+
expect(client.chat.postMessage).toHaveBeenCalledWith(
|
|
3739
|
+
expect.objectContaining({ channel: "D1", text: "Hello" }),
|
|
3740
|
+
);
|
|
3741
|
+
});
|
|
3742
|
+
|
|
3743
|
+
test("speak marker — skipped gracefully when synthesizeSpeechFn is absent", async () => {
|
|
3744
|
+
await expect(
|
|
3745
|
+
dispatchMarkers([{ type: "speak", text: "Hello" }], {
|
|
3746
|
+
client,
|
|
3747
|
+
channel: "D1",
|
|
3748
|
+
}),
|
|
3749
|
+
).resolves.toBeUndefined();
|
|
3750
|
+
|
|
3751
|
+
expect(client.files.uploadV2).not.toHaveBeenCalled();
|
|
3752
|
+
});
|
|
3753
|
+
|
|
3754
|
+
test("multiple react emojis all dispatched", async () => {
|
|
3755
|
+
await dispatchMarkers(
|
|
3756
|
+
[{ type: "react", emojis: ["thumbsup", "tada", "rocket"] }],
|
|
3757
|
+
{ client, channel: "C1", postedTs: "2.0" },
|
|
3758
|
+
);
|
|
3759
|
+
|
|
3760
|
+
expect(client.reactions.add).toHaveBeenCalledTimes(3);
|
|
3761
|
+
// biome-ignore lint/suspicious/noExplicitAny: mock call args are untyped
|
|
3762
|
+
const names = (client.reactions.add.mock.calls as any[][]).map(
|
|
3763
|
+
(c) => (c[0] as { name: string }).name,
|
|
3764
|
+
);
|
|
3765
|
+
expect(names).toEqual(["thumbsup", "tada", "rocket"]);
|
|
3766
|
+
});
|
|
3767
|
+
|
|
3768
|
+
test("react marker without postedTs does not call reactions.add", async () => {
|
|
3769
|
+
await dispatchMarkers([{ type: "react", emojis: ["thumbsup"] }], {
|
|
3770
|
+
client,
|
|
3771
|
+
channel: "C1",
|
|
3772
|
+
});
|
|
3773
|
+
expect(client.reactions.add).not.toHaveBeenCalled();
|
|
3774
|
+
});
|
|
3775
|
+
});
|
|
3776
|
+
|
|
3777
|
+
// ─── Thread resume after a first-message timeout (CSI-1.2) ────────────────────
|
|
3778
|
+
//
|
|
3779
|
+
// mockRunClaude above is a bare mock of the *runner* param — it can't exercise
|
|
3780
|
+
// the session-persistence-on-failure fix, since that logic lives inside
|
|
3781
|
+
// createRunClaude's own _runClaude. These tests wire the REAL createRunClaude
|
|
3782
|
+
// (with a fake spawner + a real in-memory session store) as the runner, so
|
|
3783
|
+
// they exercise the actual user-facing behavior: a first DM message that
|
|
3784
|
+
// times out must still let a later message in the same thread resume via
|
|
3785
|
+
// `-r <sessionId>`, instead of starting a brand-new context-free session.
|
|
3786
|
+
|
|
3787
|
+
describe("thread resume after timeout — real createRunClaude wired as runner", () => {
|
|
3788
|
+
interface FakeProc {
|
|
3789
|
+
stdout: ReadableStream<Uint8Array>;
|
|
3790
|
+
stderr: ReadableStream<Uint8Array>;
|
|
3791
|
+
exited: Promise<number>;
|
|
3792
|
+
kill: () => void;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
function bodyStream(content: string): ReadableStream<Uint8Array> {
|
|
3796
|
+
const enc = new TextEncoder();
|
|
3797
|
+
return new ReadableStream({
|
|
3798
|
+
start(controller) {
|
|
3799
|
+
controller.enqueue(enc.encode(content));
|
|
3800
|
+
controller.close();
|
|
3801
|
+
},
|
|
3802
|
+
});
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
/** A proc that emits a single `system`/`init` line (carrying a session id)
|
|
3806
|
+
* then goes silent forever — never closes on its own, only `kill()` (fired
|
|
3807
|
+
* by the idle timer) ends it. Simulates a first message that gets far
|
|
3808
|
+
* enough to create a real CLI session before hanging. */
|
|
3809
|
+
function hangingProcWithSessionId(sessionId: string): FakeProc {
|
|
3810
|
+
const enc = new TextEncoder();
|
|
3811
|
+
let resolveExited!: (code: number) => void;
|
|
3812
|
+
const exited = new Promise<number>((r) => {
|
|
3813
|
+
resolveExited = r;
|
|
3814
|
+
});
|
|
3815
|
+
let streamController!: ReadableStreamDefaultController<Uint8Array>;
|
|
3816
|
+
const stdout = new ReadableStream<Uint8Array>({
|
|
3817
|
+
start(controller) {
|
|
3818
|
+
streamController = controller;
|
|
3819
|
+
controller.enqueue(
|
|
3820
|
+
enc.encode(
|
|
3821
|
+
`${JSON.stringify({ type: "system", subtype: "init", session_id: sessionId })}\n`,
|
|
3822
|
+
),
|
|
3823
|
+
);
|
|
3824
|
+
// Deliberately never close — the idle timer's kill() is what ends it.
|
|
3825
|
+
},
|
|
3826
|
+
});
|
|
3827
|
+
return {
|
|
3828
|
+
stdout,
|
|
3829
|
+
stderr: bodyStream(""),
|
|
3830
|
+
exited,
|
|
3831
|
+
kill: () => {
|
|
3832
|
+
streamController.close();
|
|
3833
|
+
resolveExited(143);
|
|
3834
|
+
},
|
|
3835
|
+
};
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3838
|
+
function jsonResultProc(result: string, sessionId: string): FakeProc {
|
|
3839
|
+
const json = JSON.stringify({
|
|
3840
|
+
type: "result",
|
|
3841
|
+
subtype: "success",
|
|
3842
|
+
result,
|
|
3843
|
+
session_id: sessionId,
|
|
3844
|
+
is_error: false,
|
|
3845
|
+
});
|
|
3846
|
+
return {
|
|
3847
|
+
stdout: bodyStream(`${json}\n`),
|
|
3848
|
+
stderr: bodyStream(""),
|
|
3849
|
+
exited: Promise.resolve(0),
|
|
3850
|
+
kill: () => {},
|
|
3851
|
+
};
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
test("first DM message times out, second message in the same thread resumes with -r <sessionId>", async () => {
|
|
3855
|
+
// Real in-memory session store, exactly like createFileSessionStore's
|
|
3856
|
+
// contract (get/set/clear), so the runner's own _saveSession /
|
|
3857
|
+
// _saveSessionFromError calls are exercised for real.
|
|
3858
|
+
const store = new Map<string, string>();
|
|
3859
|
+
const realSessions = {
|
|
3860
|
+
get: (key: string) => store.get(key),
|
|
3861
|
+
set: (key: string, id: string) => {
|
|
3862
|
+
store.set(key, id);
|
|
3863
|
+
},
|
|
3864
|
+
clear: (key: string) => {
|
|
3865
|
+
store.delete(key);
|
|
3866
|
+
},
|
|
3867
|
+
};
|
|
3868
|
+
|
|
3869
|
+
let callCount = 0;
|
|
3870
|
+
const mockSpawn = mock(() => {
|
|
3871
|
+
callCount++;
|
|
3872
|
+
if (callCount === 1) {
|
|
3873
|
+
// First message: the CLI creates a real session, then hangs.
|
|
3874
|
+
return hangingProcWithSessionId(
|
|
3875
|
+
"sess-first-msg",
|
|
3876
|
+
) as unknown as ReturnType<typeof Bun.spawn>;
|
|
3877
|
+
}
|
|
3878
|
+
// Second message (resumed): succeeds normally.
|
|
3879
|
+
return jsonResultProc(
|
|
3880
|
+
"Resumed reply",
|
|
3881
|
+
"sess-first-msg",
|
|
3882
|
+
) as unknown as ReturnType<typeof Bun.spawn>;
|
|
3883
|
+
});
|
|
3884
|
+
|
|
3885
|
+
const realRunClaude = createRunClaude(
|
|
3886
|
+
mockSpawn as unknown as typeof Bun.spawn,
|
|
3887
|
+
realSessions,
|
|
3888
|
+
"claude-opus-4-6",
|
|
3889
|
+
"/tmp",
|
|
3890
|
+
fakeSentryClient,
|
|
3891
|
+
undefined,
|
|
3892
|
+
undefined,
|
|
3893
|
+
undefined,
|
|
3894
|
+
5000, // ceiling — nowhere near firing
|
|
3895
|
+
15, // idle timeout — fires quickly since the hanging proc goes silent
|
|
3896
|
+
);
|
|
3897
|
+
|
|
3898
|
+
capturedErrors = [];
|
|
3899
|
+
const app = _createSlackApp(
|
|
3900
|
+
(message: string, sessionKey?: string, onProgress?: ProgressCallback) =>
|
|
3901
|
+
realRunClaude(message, sessionKey, onProgress),
|
|
3902
|
+
mockMarkdownToSlack,
|
|
3903
|
+
threadKey,
|
|
3904
|
+
// biome-ignore lint/suspicious/noExplicitAny: mock factory for tests
|
|
3905
|
+
(cfg) => new MockApp(cfg as Record<string, unknown>) as any,
|
|
3906
|
+
mockSlackConfig,
|
|
3907
|
+
fakeSentryClient,
|
|
3908
|
+
async () => null,
|
|
3909
|
+
{},
|
|
3910
|
+
async () => null,
|
|
3911
|
+
async () => null,
|
|
3912
|
+
mockResolveUserFn,
|
|
3913
|
+
"UBOT123",
|
|
3914
|
+
async () => ({ messages: [] }),
|
|
3915
|
+
async (key: string) => realSessions.get(key),
|
|
3916
|
+
undefined,
|
|
3917
|
+
new NoopChatTokenReporter(),
|
|
3918
|
+
async () => undefined, // resolveUserEmailFn
|
|
3919
|
+
// A fresh, unsynced ref — never the process-wide agentSlackMembershipRef
|
|
3920
|
+
// singleton, which agent-slack-membership-ref.unit.test.ts mutates
|
|
3921
|
+
// directly (see the createSlackApp wrapper above for the identical fix).
|
|
3922
|
+
createAgentSlackMembershipRef(),
|
|
3923
|
+
);
|
|
3924
|
+
expect(app).toBeInstanceOf(MockApp);
|
|
3925
|
+
|
|
3926
|
+
const client = makeMockClient();
|
|
3927
|
+
|
|
3928
|
+
// First message in a new DM thread — times out.
|
|
3929
|
+
const firstSay = makeSay("first.reply.ts");
|
|
3930
|
+
await capturedMessageHandler?.({
|
|
3931
|
+
message: {
|
|
3932
|
+
channel: "D999",
|
|
3933
|
+
ts: "100.001",
|
|
3934
|
+
text: "Kick off a long task",
|
|
3935
|
+
channel_type: "im",
|
|
3936
|
+
},
|
|
3937
|
+
say: firstSay,
|
|
3938
|
+
client,
|
|
3939
|
+
});
|
|
3940
|
+
|
|
3941
|
+
// The timeout error was surfaced to Slack, but the session id the CLI
|
|
3942
|
+
// created before hanging must still have been persisted.
|
|
3943
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
3944
|
+
expect(store.get("D999:100.001")).toBe("sess-first-msg");
|
|
3945
|
+
|
|
3946
|
+
// Second message in the SAME thread (thread_ts pins it to the first
|
|
3947
|
+
// message's ts) — must resume, not start a fresh context-free session.
|
|
3948
|
+
const secondSay = makeSay("second.reply.ts");
|
|
3949
|
+
await capturedMessageHandler?.({
|
|
3950
|
+
message: {
|
|
3951
|
+
channel: "D999",
|
|
3952
|
+
ts: "100.002",
|
|
3953
|
+
thread_ts: "100.001",
|
|
3954
|
+
text: "Are you still there?",
|
|
3955
|
+
channel_type: "im",
|
|
3956
|
+
},
|
|
3957
|
+
say: secondSay,
|
|
3958
|
+
client,
|
|
3959
|
+
});
|
|
3960
|
+
|
|
3961
|
+
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
3962
|
+
const [secondCmd] = mockSpawn.mock.calls[1] as unknown as [string[]];
|
|
3963
|
+
const rIdx = secondCmd.indexOf("-r");
|
|
3964
|
+
expect(rIdx).toBeGreaterThan(-1);
|
|
3965
|
+
expect(secondCmd[rIdx + 1]).toBe("sess-first-msg");
|
|
3966
|
+
// Delivered via the Thinking Steps stream (client has a working chat
|
|
3967
|
+
// namespace) — say() is not used for the real reply.
|
|
3968
|
+
expect(secondSay).not.toHaveBeenCalled();
|
|
3969
|
+
const markdownChunks = client.chat.appendStream.mock.calls
|
|
3970
|
+
.flatMap(
|
|
3971
|
+
(c) => (c[0] as { chunks: Array<Record<string, unknown>> }).chunks,
|
|
3972
|
+
)
|
|
3973
|
+
.filter((chunk) => chunk.type === "markdown_text");
|
|
3974
|
+
expect(markdownChunks).toContainEqual({
|
|
3975
|
+
type: "markdown_text",
|
|
3976
|
+
text: "Resumed reply",
|
|
3977
|
+
});
|
|
3978
|
+
});
|
|
3979
|
+
});
|