@app-vitals/agent-runtime 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +50 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for pure helpers in agent/src/runtime/slack.ts.
|
|
3
|
+
*
|
|
4
|
+
* hasSlackCredentials gates whether the agent boots its Slack Bolt App. Bolt's
|
|
5
|
+
* Socket Mode throws "Must provide an App-Level Token" when constructed without
|
|
6
|
+
* a non-empty appToken, so the agent must only call createSlackApp when both the
|
|
7
|
+
* bot token and the app-level token are present. Absent creds → offline mode.
|
|
8
|
+
*
|
|
9
|
+
* The isAllowedSlackSender + handler-level membership-gating tests below cover
|
|
10
|
+
* SMR-4.1: a single shared gate, called at the top of message/app_mention/
|
|
11
|
+
* reaction_added, before runner() is invoked. This file builds its own minimal
|
|
12
|
+
* MockApp harness (mirroring slack.integration.test.ts's pattern) so these
|
|
13
|
+
* cases are self-contained and slack.integration.test.ts stays untouched —
|
|
14
|
+
* its existing "processed as today" assertions continue to exercise the same
|
|
15
|
+
* default fail-open code path (unsynced ref / restrict=false).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
19
|
+
import type { AgentSlackMembershipRef } from "./agent-slack-membership-ref.ts";
|
|
20
|
+
import { createAgentSlackMembershipRef } from "./agent-slack-membership-ref.ts";
|
|
21
|
+
import {
|
|
22
|
+
createSlackApp as _createSlackApp,
|
|
23
|
+
dispatchMarkers,
|
|
24
|
+
hasSlackCredentials,
|
|
25
|
+
isAllowedSlackSender,
|
|
26
|
+
} from "./slack.ts";
|
|
27
|
+
|
|
28
|
+
describe("hasSlackCredentials", () => {
|
|
29
|
+
test("true when both bot and app tokens are non-empty", () => {
|
|
30
|
+
expect(
|
|
31
|
+
hasSlackCredentials({ botToken: "xoxb-1", appToken: "xapp-1" }),
|
|
32
|
+
).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("false when app token is missing (the Socket Mode requirement)", () => {
|
|
36
|
+
expect(hasSlackCredentials({ botToken: "xoxb-1", appToken: "" })).toBe(
|
|
37
|
+
false,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("false when bot token is missing", () => {
|
|
42
|
+
expect(hasSlackCredentials({ botToken: "", appToken: "xapp-1" })).toBe(
|
|
43
|
+
false,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("false when both are empty (offline dev default)", () => {
|
|
48
|
+
expect(hasSlackCredentials({ botToken: "", appToken: "" })).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("treats whitespace-only tokens as absent", () => {
|
|
52
|
+
expect(hasSlackCredentials({ botToken: " ", appToken: " " })).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ─── dispatchMarkers — plan marker ──────────────────────────────────────────
|
|
57
|
+
// Injects a plain-object Slack client double (no mock.module, no global
|
|
58
|
+
// override) and asserts the [plan:url] marker posts a "View plan" message to
|
|
59
|
+
// the bound channel/thread.
|
|
60
|
+
|
|
61
|
+
describe("dispatchMarkers — plan marker", () => {
|
|
62
|
+
test("posts a View plan message to the bound channel/thread", async () => {
|
|
63
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
64
|
+
const calls: any[] = [];
|
|
65
|
+
const client = {
|
|
66
|
+
chat: {
|
|
67
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
68
|
+
postMessage: async (a: any) => {
|
|
69
|
+
calls.push(a);
|
|
70
|
+
return { ok: true };
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await dispatchMarkers(
|
|
76
|
+
[{ type: "plan", url: "https://example.com/p/abc" }],
|
|
77
|
+
{ client, channel: "C123", threadTs: "1700.1" },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(calls).toHaveLength(1);
|
|
81
|
+
const call = calls[0];
|
|
82
|
+
expect(call.channel).toBe("C123");
|
|
83
|
+
expect(call.thread_ts).toBe("1700.1");
|
|
84
|
+
expect(JSON.stringify(call.blocks)).toContain("https://example.com/p/abc");
|
|
85
|
+
expect(call.text).toContain("https://example.com/p/abc");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ─── isAllowedSlackSender ───────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
describe("isAllowedSlackSender", () => {
|
|
92
|
+
test("allows when the membership ref has never synced, regardless of email", () => {
|
|
93
|
+
const ref = createAgentSlackMembershipRef();
|
|
94
|
+
expect(isAllowedSlackSender(undefined, ref)).toBe(true);
|
|
95
|
+
expect(isAllowedSlackSender("nobody@example.com", ref)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("allows when synced but restrict is false", () => {
|
|
99
|
+
const ref = createAgentSlackMembershipRef();
|
|
100
|
+
ref.set({ restrict: false, emails: ["dan@example.com"] });
|
|
101
|
+
expect(isAllowedSlackSender(undefined, ref)).toBe(true);
|
|
102
|
+
expect(isAllowedSlackSender("someone-else@example.com", ref)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("allows a matching email (exact case)", () => {
|
|
106
|
+
const ref = createAgentSlackMembershipRef();
|
|
107
|
+
ref.set({ restrict: true, emails: ["dan@example.com"] });
|
|
108
|
+
expect(isAllowedSlackSender("dan@example.com", ref)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("allows a matching email case-insensitively", () => {
|
|
112
|
+
const ref = createAgentSlackMembershipRef();
|
|
113
|
+
ref.set({ restrict: true, emails: ["Dan@Example.com"] });
|
|
114
|
+
expect(isAllowedSlackSender("dan@example.com", ref)).toBe(true);
|
|
115
|
+
expect(isAllowedSlackSender("DAN@EXAMPLE.COM", ref)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("rejects a non-matching email", () => {
|
|
119
|
+
const ref = createAgentSlackMembershipRef();
|
|
120
|
+
ref.set({ restrict: true, emails: ["dan@example.com"] });
|
|
121
|
+
expect(isAllowedSlackSender("stranger@example.com", ref)).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("rejects an undefined email when restrict is true", () => {
|
|
125
|
+
const ref = createAgentSlackMembershipRef();
|
|
126
|
+
ref.set({ restrict: true, emails: ["dan@example.com"] });
|
|
127
|
+
expect(isAllowedSlackSender(undefined, ref)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ─── Handler-level membership gating ───────────────────────────────────────
|
|
132
|
+
// Self-contained MockApp harness (mirrors slack.integration.test.ts) scoped
|
|
133
|
+
// to this file — captures constructor args + registered handlers so the
|
|
134
|
+
// three gating call sites can be exercised without duplicating the 3000+
|
|
135
|
+
// lines of unrelated setup living in slack.integration.test.ts.
|
|
136
|
+
|
|
137
|
+
type HandlerFn = (...args: unknown[]) => Promise<void>;
|
|
138
|
+
|
|
139
|
+
let capturedMessageHandler: HandlerFn | null = null;
|
|
140
|
+
let capturedMentionHandler: HandlerFn | null = null;
|
|
141
|
+
let capturedReactionAddedHandler: HandlerFn | null = null;
|
|
142
|
+
let capturedAgentSessionStoppedHandler: HandlerFn | null = null;
|
|
143
|
+
|
|
144
|
+
class MockApp {
|
|
145
|
+
constructor(_args: Record<string, unknown>) {
|
|
146
|
+
capturedMessageHandler = null;
|
|
147
|
+
capturedMentionHandler = null;
|
|
148
|
+
capturedReactionAddedHandler = null;
|
|
149
|
+
capturedAgentSessionStoppedHandler = null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
message(handler: HandlerFn) {
|
|
153
|
+
capturedMessageHandler = handler;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
event(type: string, handler: HandlerFn) {
|
|
157
|
+
if (type === "app_mention") capturedMentionHandler = handler;
|
|
158
|
+
if (type === "reaction_added") capturedReactionAddedHandler = handler;
|
|
159
|
+
if (type === "agent_session_stopped")
|
|
160
|
+
capturedAgentSessionStoppedHandler = handler;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const mockGatingSlackConfig = {
|
|
165
|
+
botToken: "xoxb-test-token",
|
|
166
|
+
appToken: "xapp-test-token",
|
|
167
|
+
signingSecret: "test-secret",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
function makeMockClient() {
|
|
171
|
+
return {
|
|
172
|
+
agents: {
|
|
173
|
+
sessions: {
|
|
174
|
+
setStatus: mock(async (_args: unknown) => {}),
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
reactions: {
|
|
178
|
+
add: mock(async (_args: unknown) => {}),
|
|
179
|
+
},
|
|
180
|
+
files: {
|
|
181
|
+
uploadV2: mock(async (_args: unknown) => {}),
|
|
182
|
+
},
|
|
183
|
+
chat: {
|
|
184
|
+
postMessage: mock(async (_args: unknown) => ({ ts: "resp.ts.1" })),
|
|
185
|
+
startStream: mock(async (_args: unknown) => ({ ts: "stream.ts.1" })),
|
|
186
|
+
appendStream: mock(async (_args: unknown) => {}),
|
|
187
|
+
stopStream: mock(async (_args: unknown) => {}),
|
|
188
|
+
},
|
|
189
|
+
users: {
|
|
190
|
+
info: mock(async (_args: unknown) => ({
|
|
191
|
+
user: {
|
|
192
|
+
profile: { display_name: "Test User", email: "member@example.com" },
|
|
193
|
+
name: "testuser",
|
|
194
|
+
},
|
|
195
|
+
})),
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function makeSay() {
|
|
201
|
+
return mock(async (_args: unknown) => ({ ts: "reply.ts.1" }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function setupGatingApp(overrides: {
|
|
205
|
+
membershipRef: AgentSlackMembershipRef;
|
|
206
|
+
resolveUserEmailFn: (
|
|
207
|
+
userId: string,
|
|
208
|
+
client: unknown,
|
|
209
|
+
) => Promise<string | undefined>;
|
|
210
|
+
runner?: (
|
|
211
|
+
message: string,
|
|
212
|
+
sessionKey?: string,
|
|
213
|
+
) => Promise<{
|
|
214
|
+
result: string;
|
|
215
|
+
sessionId?: string;
|
|
216
|
+
streamIncomplete?: boolean;
|
|
217
|
+
}>;
|
|
218
|
+
getSessionFn?: (key: string) => Promise<string | undefined>;
|
|
219
|
+
}) {
|
|
220
|
+
const runner =
|
|
221
|
+
overrides.runner ??
|
|
222
|
+
mock(async (_msg: string, _key?: string) => ({
|
|
223
|
+
result: "Claude response",
|
|
224
|
+
sessionId: "sess-1",
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
_createSlackApp(
|
|
228
|
+
runner,
|
|
229
|
+
(text: string) => text,
|
|
230
|
+
(channel: string, ts: string) => `${channel}:${ts}`,
|
|
231
|
+
// biome-ignore lint/suspicious/noExplicitAny: mock factory for tests
|
|
232
|
+
(cfg) => new MockApp(cfg as Record<string, unknown>) as any,
|
|
233
|
+
mockGatingSlackConfig,
|
|
234
|
+
undefined, // sentryClient — default noop
|
|
235
|
+
async () => null, // fileDownloaderFn
|
|
236
|
+
{}, // voiceConfig
|
|
237
|
+
async () => null, // transcribeAudioFn
|
|
238
|
+
async () => null, // synthesizeSpeechFn
|
|
239
|
+
async (userId: string) => userId, // resolveUserFn (display name) — identity
|
|
240
|
+
"UBOT123", // botUserId
|
|
241
|
+
async () => ({ messages: [] }), // conversationsRepliesFn
|
|
242
|
+
overrides.getSessionFn ?? (async () => undefined), // getSessionFn
|
|
243
|
+
undefined, // blocksConverter — default
|
|
244
|
+
undefined, // chatTokenReporter — default noop
|
|
245
|
+
overrides.resolveUserEmailFn,
|
|
246
|
+
overrides.membershipRef,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
return { runner };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
describe("membership gating — message handler", () => {
|
|
253
|
+
async function invokeDM(
|
|
254
|
+
membershipRef: AgentSlackMembershipRef,
|
|
255
|
+
resolveUserEmailFn: (
|
|
256
|
+
userId: string,
|
|
257
|
+
client: unknown,
|
|
258
|
+
) => Promise<string | undefined>,
|
|
259
|
+
) {
|
|
260
|
+
const { runner } = setupGatingApp({ membershipRef, resolveUserEmailFn });
|
|
261
|
+
const client = makeMockClient();
|
|
262
|
+
const say = makeSay();
|
|
263
|
+
const message = {
|
|
264
|
+
channel: "D123",
|
|
265
|
+
ts: "111.222",
|
|
266
|
+
text: "Hello bot",
|
|
267
|
+
channel_type: "im",
|
|
268
|
+
user: "U-SENDER",
|
|
269
|
+
};
|
|
270
|
+
await capturedMessageHandler?.({ message, say, client });
|
|
271
|
+
return { runner, say, client };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
test("unsynced ref: processed as today (runner called)", async () => {
|
|
275
|
+
const ref = createAgentSlackMembershipRef();
|
|
276
|
+
const { runner, client } = await invokeDM(ref, async () => undefined);
|
|
277
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
278
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("restrict=false: processed as today (runner called)", async () => {
|
|
282
|
+
const ref = createAgentSlackMembershipRef();
|
|
283
|
+
ref.set({ restrict: false, emails: [] });
|
|
284
|
+
const { runner, client } = await invokeDM(ref, async () => undefined);
|
|
285
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
286
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("restrict=true + matching email: processed as today", async () => {
|
|
290
|
+
const ref = createAgentSlackMembershipRef();
|
|
291
|
+
ref.set({ restrict: true, emails: ["member@example.com"] });
|
|
292
|
+
const { runner, client } = await invokeDM(
|
|
293
|
+
ref,
|
|
294
|
+
async () => "member@example.com",
|
|
295
|
+
);
|
|
296
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
297
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("restrict=true + non-member email: runner not called, no reply posted", async () => {
|
|
301
|
+
const ref = createAgentSlackMembershipRef();
|
|
302
|
+
ref.set({ restrict: true, emails: ["member@example.com"] });
|
|
303
|
+
const { runner, say } = await invokeDM(
|
|
304
|
+
ref,
|
|
305
|
+
async () => "stranger@example.com",
|
|
306
|
+
);
|
|
307
|
+
expect(runner).not.toHaveBeenCalled();
|
|
308
|
+
expect(say).not.toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("restrict=true + undefined email (resolution failed): runner not called", async () => {
|
|
312
|
+
const ref = createAgentSlackMembershipRef();
|
|
313
|
+
ref.set({ restrict: true, emails: ["member@example.com"] });
|
|
314
|
+
const { runner, say } = await invokeDM(ref, async () => undefined);
|
|
315
|
+
expect(runner).not.toHaveBeenCalled();
|
|
316
|
+
expect(say).not.toHaveBeenCalled();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("membership gating — app_mention handler", () => {
|
|
321
|
+
async function invokeMention(
|
|
322
|
+
membershipRef: AgentSlackMembershipRef,
|
|
323
|
+
resolveUserEmailFn: (
|
|
324
|
+
userId: string,
|
|
325
|
+
client: unknown,
|
|
326
|
+
) => Promise<string | undefined>,
|
|
327
|
+
) {
|
|
328
|
+
const { runner } = setupGatingApp({ membershipRef, resolveUserEmailFn });
|
|
329
|
+
const client = makeMockClient();
|
|
330
|
+
const say = makeSay();
|
|
331
|
+
const event = {
|
|
332
|
+
text: "<@UBOT> do something",
|
|
333
|
+
channel: "C999",
|
|
334
|
+
ts: "222.333",
|
|
335
|
+
user: "U-SENDER",
|
|
336
|
+
};
|
|
337
|
+
await capturedMentionHandler?.({ event, say, client });
|
|
338
|
+
return { runner, say, client };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
test("unsynced ref: processed as today (runner called)", async () => {
|
|
342
|
+
const ref = createAgentSlackMembershipRef();
|
|
343
|
+
const { runner, client } = await invokeMention(ref, async () => undefined);
|
|
344
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
345
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("restrict=false: processed as today (runner called)", async () => {
|
|
349
|
+
const ref = createAgentSlackMembershipRef();
|
|
350
|
+
ref.set({ restrict: false, emails: [] });
|
|
351
|
+
const { runner, client } = await invokeMention(ref, async () => undefined);
|
|
352
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
353
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("restrict=true + matching email: processed as today", async () => {
|
|
357
|
+
const ref = createAgentSlackMembershipRef();
|
|
358
|
+
ref.set({ restrict: true, emails: ["member@example.com"] });
|
|
359
|
+
const { runner, client } = await invokeMention(
|
|
360
|
+
ref,
|
|
361
|
+
async () => "MEMBER@example.com",
|
|
362
|
+
);
|
|
363
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
364
|
+
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("restrict=true + non-member email: runner not called, no reply posted", async () => {
|
|
368
|
+
const ref = createAgentSlackMembershipRef();
|
|
369
|
+
ref.set({ restrict: true, emails: ["member@example.com"] });
|
|
370
|
+
const { runner, say } = await invokeMention(
|
|
371
|
+
ref,
|
|
372
|
+
async () => "stranger@example.com",
|
|
373
|
+
);
|
|
374
|
+
expect(runner).not.toHaveBeenCalled();
|
|
375
|
+
expect(say).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("restrict=true + undefined email (resolution failed): runner not called", async () => {
|
|
379
|
+
const ref = createAgentSlackMembershipRef();
|
|
380
|
+
ref.set({ restrict: true, emails: ["member@example.com"] });
|
|
381
|
+
const { runner, say } = await invokeMention(ref, async () => undefined);
|
|
382
|
+
expect(runner).not.toHaveBeenCalled();
|
|
383
|
+
expect(say).not.toHaveBeenCalled();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ─── Per-thread Thinking Steps refcounting (SSF-1.1, streaming per STS2-1.1) ─
|
|
388
|
+
// A burst of overlapping message-handler invocations on the same thread each
|
|
389
|
+
// get their own independent chat.startStream/stopStream lifecycle — the
|
|
390
|
+
// ThreadStatusTracker refcount only selects the seed card's title
|
|
391
|
+
// ("Queued…" vs "Thinking…") and the session_status stopStream closes with
|
|
392
|
+
// ("processing" while other messages are still in flight, "active" only for
|
|
393
|
+
// the genuine last one). This exercises the tracker wired into
|
|
394
|
+
// createSlackApp via the same MockApp/mock-client harness used by the
|
|
395
|
+
// membership-gating suite above.
|
|
396
|
+
|
|
397
|
+
// Flushes pending microtask hops (getSessionFn / shouldRejectSlackSender's
|
|
398
|
+
// resolveUserEmailFn awaits, etc.) that run before a handler reaches
|
|
399
|
+
// progress.start(), so assertions made right after firing a handler call
|
|
400
|
+
// observe state as of "just before runner() is invoked" rather than mid-flight.
|
|
401
|
+
function flushMicrotasks(): Promise<void> {
|
|
402
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function seedTitles(client: ReturnType<typeof makeMockClient>): string[] {
|
|
406
|
+
return client.chat.appendStream.mock.calls
|
|
407
|
+
.map(
|
|
408
|
+
(c) =>
|
|
409
|
+
(c[0] as { chunks: Array<Record<string, unknown>> }).chunks.find(
|
|
410
|
+
(chunk) => chunk.type === "task_update",
|
|
411
|
+
)?.title,
|
|
412
|
+
)
|
|
413
|
+
.filter((t): t is string => typeof t === "string" && t.endsWith("…"));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function stopStreamStatuses(
|
|
417
|
+
client: ReturnType<typeof makeMockClient>,
|
|
418
|
+
): string[] {
|
|
419
|
+
return client.chat.stopStream.mock.calls.map(
|
|
420
|
+
(c: unknown[]) => (c[0] as { session_status: string }).session_status,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
describe("per-thread Thinking Steps refcounting — message handler burst", () => {
|
|
425
|
+
test("2 overlapping runs on the same thread: 'Thinking…' then 'Queued…' seeds, 'processing' then 'active' stops", async () => {
|
|
426
|
+
// Deferred promises let the test control exactly when each runner()
|
|
427
|
+
// invocation resolves. Keyed by the prompt text (which embeds the
|
|
428
|
+
// message body) rather than call order — resolveUserFn's await means the
|
|
429
|
+
// two concurrent handler invocations can reach runner() in either order,
|
|
430
|
+
// so the mock must resolve the correct in-flight call regardless of
|
|
431
|
+
// which one actually invoked runner() first.
|
|
432
|
+
let resolveFirst!: (value: { result: string; sessionId?: string }) => void;
|
|
433
|
+
let resolveSecond!: (value: { result: string; sessionId?: string }) => void;
|
|
434
|
+
const firstDone = new Promise<{ result: string; sessionId?: string }>(
|
|
435
|
+
(resolve) => {
|
|
436
|
+
resolveFirst = resolve;
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
const secondDone = new Promise<{ result: string; sessionId?: string }>(
|
|
440
|
+
(resolve) => {
|
|
441
|
+
resolveSecond = resolve;
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const runner = mock(async (msg: string, _key?: string) =>
|
|
446
|
+
msg.includes("second message") ? secondDone : firstDone,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const ref = createAgentSlackMembershipRef();
|
|
450
|
+
setupGatingApp({
|
|
451
|
+
membershipRef: ref,
|
|
452
|
+
resolveUserEmailFn: async () => undefined,
|
|
453
|
+
runner,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const client = makeMockClient();
|
|
457
|
+
const say = makeSay();
|
|
458
|
+
// Same thread_ts on both messages — sessionKey is derived from
|
|
459
|
+
// (channel, thread_ts ?? ts), so a burst on one thread must share a
|
|
460
|
+
// thread_ts even though each individual message has its own ts.
|
|
461
|
+
const message = {
|
|
462
|
+
channel: "D123",
|
|
463
|
+
ts: "300.1",
|
|
464
|
+
thread_ts: "300.1",
|
|
465
|
+
text: "first message",
|
|
466
|
+
channel_type: "im",
|
|
467
|
+
user: "U-SENDER",
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Fire both handler invocations before either runner() call resolves —
|
|
471
|
+
// simulates a burst of 2 messages queued on the same thread while a run
|
|
472
|
+
// is in flight.
|
|
473
|
+
const firstHandlerCall = capturedMessageHandler?.({
|
|
474
|
+
message,
|
|
475
|
+
say,
|
|
476
|
+
client,
|
|
477
|
+
});
|
|
478
|
+
const secondHandlerCall = capturedMessageHandler?.({
|
|
479
|
+
message: { ...message, ts: "300.2", text: "second message" },
|
|
480
|
+
say,
|
|
481
|
+
client,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
await flushMicrotasks();
|
|
485
|
+
|
|
486
|
+
// Both messages have already opened their own stream by now — the first
|
|
487
|
+
// seeded "Thinking…" (0->1 transition), the second "Queued…" (still 1
|
|
488
|
+
// in flight) — and neither handler has reached its finally block yet.
|
|
489
|
+
expect(client.chat.startStream).toHaveBeenCalledTimes(2);
|
|
490
|
+
expect(seedTitles(client)).toEqual(["Thinking…", "Queued…"]);
|
|
491
|
+
expect(client.chat.stopStream).not.toHaveBeenCalled();
|
|
492
|
+
|
|
493
|
+
// Resolve the FIRST run — since a second run is still in flight, this
|
|
494
|
+
// must close with session_status "processing", not "active" (the false
|
|
495
|
+
// "done" signal this refcount prevents).
|
|
496
|
+
resolveFirst({ result: "first done", sessionId: "sess-1" });
|
|
497
|
+
await firstHandlerCall;
|
|
498
|
+
|
|
499
|
+
expect(client.chat.stopStream).toHaveBeenCalledTimes(1);
|
|
500
|
+
expect(stopStreamStatuses(client)).toEqual(["processing"]);
|
|
501
|
+
|
|
502
|
+
// Resolve the SECOND (last) run — only now should stopStream close with
|
|
503
|
+
// "active".
|
|
504
|
+
resolveSecond({ result: "second done", sessionId: "sess-1" });
|
|
505
|
+
await secondHandlerCall;
|
|
506
|
+
|
|
507
|
+
expect(client.chat.stopStream).toHaveBeenCalledTimes(2);
|
|
508
|
+
expect(stopStreamStatuses(client)).toEqual(["processing", "active"]);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("bursts on different threads do not interfere with each other's seed titles or stop statuses", async () => {
|
|
512
|
+
let resolveA!: (value: { result: string; sessionId?: string }) => void;
|
|
513
|
+
let resolveB!: (value: { result: string; sessionId?: string }) => void;
|
|
514
|
+
const doneA = new Promise<{ result: string; sessionId?: string }>(
|
|
515
|
+
(resolve) => {
|
|
516
|
+
resolveA = resolve;
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
const doneB = new Promise<{ result: string; sessionId?: string }>(
|
|
520
|
+
(resolve) => {
|
|
521
|
+
resolveB = resolve;
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
const runner = mock(async (msg: string, _key?: string) =>
|
|
526
|
+
msg.includes("channel-a") ? doneA : doneB,
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
const ref = createAgentSlackMembershipRef();
|
|
530
|
+
setupGatingApp({
|
|
531
|
+
membershipRef: ref,
|
|
532
|
+
resolveUserEmailFn: async () => undefined,
|
|
533
|
+
runner,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const client = makeMockClient();
|
|
537
|
+
const say = makeSay();
|
|
538
|
+
|
|
539
|
+
const callA = capturedMessageHandler?.({
|
|
540
|
+
message: {
|
|
541
|
+
channel: "D-A",
|
|
542
|
+
ts: "400.1",
|
|
543
|
+
text: "message on channel-a",
|
|
544
|
+
channel_type: "im",
|
|
545
|
+
user: "U-SENDER",
|
|
546
|
+
},
|
|
547
|
+
say,
|
|
548
|
+
client,
|
|
549
|
+
});
|
|
550
|
+
const callB = capturedMessageHandler?.({
|
|
551
|
+
message: {
|
|
552
|
+
channel: "D-B",
|
|
553
|
+
ts: "500.1",
|
|
554
|
+
text: "message on channel-b",
|
|
555
|
+
channel_type: "im",
|
|
556
|
+
user: "U-SENDER",
|
|
557
|
+
},
|
|
558
|
+
say,
|
|
559
|
+
client,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
await flushMicrotasks();
|
|
563
|
+
|
|
564
|
+
// Two independent threads, both starting fresh — both seed "Thinking…",
|
|
565
|
+
// neither "Queued…" (each is the first/only run on its own thread).
|
|
566
|
+
expect(client.chat.startStream).toHaveBeenCalledTimes(2);
|
|
567
|
+
expect(seedTitles(client)).toEqual(["Thinking…", "Thinking…"]);
|
|
568
|
+
|
|
569
|
+
resolveA({ result: "a done", sessionId: "sess-a" });
|
|
570
|
+
await callA;
|
|
571
|
+
resolveB({ result: "b done", sessionId: "sess-b" });
|
|
572
|
+
await callB;
|
|
573
|
+
|
|
574
|
+
// Each thread's single run completing should independently close with
|
|
575
|
+
// session_status "active" (never "processing" — neither thread ever had
|
|
576
|
+
// a second overlapping run).
|
|
577
|
+
expect(client.chat.stopStream).toHaveBeenCalledTimes(2);
|
|
578
|
+
expect(stopStreamStatuses(client)).toEqual(["active", "active"]);
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
describe("membership gating — reaction_added handler", () => {
|
|
583
|
+
async function invokeReaction(
|
|
584
|
+
membershipRef: AgentSlackMembershipRef,
|
|
585
|
+
resolveUserEmailFn: (
|
|
586
|
+
userId: string,
|
|
587
|
+
client: unknown,
|
|
588
|
+
) => Promise<string | undefined>,
|
|
589
|
+
) {
|
|
590
|
+
const { runner } = setupGatingApp({ membershipRef, resolveUserEmailFn });
|
|
591
|
+
const client = makeMockClient();
|
|
592
|
+
const event = {
|
|
593
|
+
reaction: "thumbsup",
|
|
594
|
+
item: { type: "message", channel: "D1", ts: "100.1" },
|
|
595
|
+
item_user: "UBOT123",
|
|
596
|
+
user: "U-SENDER",
|
|
597
|
+
};
|
|
598
|
+
await capturedReactionAddedHandler?.({ event, client });
|
|
599
|
+
return { runner, client };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
test("unsynced ref: processed as today (runner called)", async () => {
|
|
603
|
+
const ref = createAgentSlackMembershipRef();
|
|
604
|
+
const { runner } = await invokeReaction(ref, async () => undefined);
|
|
605
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("restrict=false: processed as today (runner called)", async () => {
|
|
609
|
+
const ref = createAgentSlackMembershipRef();
|
|
610
|
+
ref.set({ restrict: false, emails: [] });
|
|
611
|
+
const { runner } = await invokeReaction(ref, async () => undefined);
|
|
612
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("restrict=true + matching email: processed as today", async () => {
|
|
616
|
+
const ref = createAgentSlackMembershipRef();
|
|
617
|
+
ref.set({ restrict: true, emails: ["member@example.com"] });
|
|
618
|
+
const { runner } = await invokeReaction(
|
|
619
|
+
ref,
|
|
620
|
+
async () => "member@example.com",
|
|
621
|
+
);
|
|
622
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("restrict=true + non-member email: runner not called, no post", async () => {
|
|
626
|
+
const ref = createAgentSlackMembershipRef();
|
|
627
|
+
ref.set({ restrict: true, emails: ["member@example.com"] });
|
|
628
|
+
const { runner, client } = await invokeReaction(
|
|
629
|
+
ref,
|
|
630
|
+
async () => "stranger@example.com",
|
|
631
|
+
);
|
|
632
|
+
expect(runner).not.toHaveBeenCalled();
|
|
633
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("restrict=true + undefined email (resolution failed): runner not called", async () => {
|
|
637
|
+
const ref = createAgentSlackMembershipRef();
|
|
638
|
+
ref.set({ restrict: true, emails: ["member@example.com"] });
|
|
639
|
+
const { runner, client } = await invokeReaction(ref, async () => undefined);
|
|
640
|
+
expect(runner).not.toHaveBeenCalled();
|
|
641
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// ─── extraEnv passed to runner (STC-1.2) ───────────────────────────────────
|
|
646
|
+
// Each Slack-originated runner() call should tell the run which Slack
|
|
647
|
+
// channel/thread it serves via extraEnv, using exactly the same ts that was
|
|
648
|
+
// used to build the session key (getThreadKey's second argument) — not
|
|
649
|
+
// necessarily the raw event ts, since thread_ts wins when present.
|
|
650
|
+
|
|
651
|
+
describe("extraEnv passed to runner — SLACK_CHANNEL_ID / SLACK_THREAD_TS (STC-1.2)", () => {
|
|
652
|
+
test("DM message: extraEnv uses the message channel and ts (no thread_ts)", async () => {
|
|
653
|
+
const ref = createAgentSlackMembershipRef();
|
|
654
|
+
const runner = mock(async (_msg: string, _key?: string) => ({
|
|
655
|
+
result: "ok",
|
|
656
|
+
sessionId: "sess-1",
|
|
657
|
+
}));
|
|
658
|
+
setupGatingApp({
|
|
659
|
+
membershipRef: ref,
|
|
660
|
+
resolveUserEmailFn: async () => undefined,
|
|
661
|
+
runner,
|
|
662
|
+
});
|
|
663
|
+
const client = makeMockClient();
|
|
664
|
+
const say = makeSay();
|
|
665
|
+
const message = {
|
|
666
|
+
channel: "D0EXAMPLE",
|
|
667
|
+
ts: "1700000000.000100",
|
|
668
|
+
text: "Hello bot",
|
|
669
|
+
channel_type: "im",
|
|
670
|
+
user: "U-SENDER",
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
await capturedMessageHandler?.({ message, say, client });
|
|
674
|
+
|
|
675
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
676
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
677
|
+
const call = (runner as any).mock.calls[0];
|
|
678
|
+
expect(call[3]).toEqual({
|
|
679
|
+
SLACK_CHANNEL_ID: "D0EXAMPLE",
|
|
680
|
+
SLACK_THREAD_TS: "1700000000.000100",
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("channel-thread message: extraEnv uses thread_ts, not the message's own ts", async () => {
|
|
685
|
+
const ref = createAgentSlackMembershipRef();
|
|
686
|
+
const runner = mock(async (_msg: string, _key?: string) => ({
|
|
687
|
+
result: "ok",
|
|
688
|
+
sessionId: "sess-1",
|
|
689
|
+
}));
|
|
690
|
+
setupGatingApp({
|
|
691
|
+
membershipRef: ref,
|
|
692
|
+
resolveUserEmailFn: async () => undefined,
|
|
693
|
+
runner,
|
|
694
|
+
// Non-DM channel messages only run when the bot already has a tracked
|
|
695
|
+
// session for the thread (see the `if (!isDM)` gate in slack.ts).
|
|
696
|
+
getSessionFn: async () => "existing-session-id",
|
|
697
|
+
});
|
|
698
|
+
const client = makeMockClient();
|
|
699
|
+
const say = makeSay();
|
|
700
|
+
const message = {
|
|
701
|
+
channel: "C0EXAMPLE",
|
|
702
|
+
ts: "1700000000.000200",
|
|
703
|
+
thread_ts: "1700000000.000150",
|
|
704
|
+
text: "reply in thread",
|
|
705
|
+
channel_type: "channel",
|
|
706
|
+
user: "U-SENDER",
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
await capturedMessageHandler?.({ message, say, client });
|
|
710
|
+
|
|
711
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
712
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
713
|
+
const call = (runner as any).mock.calls[0];
|
|
714
|
+
expect(call[3]).toEqual({
|
|
715
|
+
SLACK_CHANNEL_ID: "C0EXAMPLE",
|
|
716
|
+
SLACK_THREAD_TS: "1700000000.000150",
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("app_mention, new thread: extraEnv uses the event's own ts (no thread_ts)", async () => {
|
|
721
|
+
const ref = createAgentSlackMembershipRef();
|
|
722
|
+
const runner = mock(async (_msg: string, _key?: string) => ({
|
|
723
|
+
result: "ok",
|
|
724
|
+
sessionId: "sess-1",
|
|
725
|
+
}));
|
|
726
|
+
setupGatingApp({
|
|
727
|
+
membershipRef: ref,
|
|
728
|
+
resolveUserEmailFn: async () => undefined,
|
|
729
|
+
runner,
|
|
730
|
+
});
|
|
731
|
+
const client = makeMockClient();
|
|
732
|
+
const say = makeSay();
|
|
733
|
+
const event = {
|
|
734
|
+
text: "<@UBOT> do something",
|
|
735
|
+
channel: "C0EXAMPLE",
|
|
736
|
+
ts: "1700000000.000300",
|
|
737
|
+
user: "U-SENDER",
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
await capturedMentionHandler?.({ event, say, client });
|
|
741
|
+
|
|
742
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
743
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
744
|
+
const call = (runner as any).mock.calls[0];
|
|
745
|
+
expect(call[3]).toEqual({
|
|
746
|
+
SLACK_CHANNEL_ID: "C0EXAMPLE",
|
|
747
|
+
SLACK_THREAD_TS: "1700000000.000300",
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("app_mention, existing thread: extraEnv uses thread_ts, not the event's own ts", async () => {
|
|
752
|
+
const ref = createAgentSlackMembershipRef();
|
|
753
|
+
const runner = mock(async (_msg: string, _key?: string) => ({
|
|
754
|
+
result: "ok",
|
|
755
|
+
sessionId: "sess-1",
|
|
756
|
+
}));
|
|
757
|
+
setupGatingApp({
|
|
758
|
+
membershipRef: ref,
|
|
759
|
+
resolveUserEmailFn: async () => undefined,
|
|
760
|
+
runner,
|
|
761
|
+
// No tracked session yet for this thread — otherwise the app_mention
|
|
762
|
+
// handler drops the mention entirely (the message handler already
|
|
763
|
+
// covers threads the bot is participating in).
|
|
764
|
+
getSessionFn: async () => undefined,
|
|
765
|
+
});
|
|
766
|
+
const client = makeMockClient();
|
|
767
|
+
const say = makeSay();
|
|
768
|
+
const event = {
|
|
769
|
+
text: "<@UBOT> do something else",
|
|
770
|
+
channel: "C0EXAMPLE",
|
|
771
|
+
ts: "1700000000.000450",
|
|
772
|
+
thread_ts: "1700000000.000400",
|
|
773
|
+
user: "U-SENDER",
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
await capturedMentionHandler?.({ event, say, client });
|
|
777
|
+
|
|
778
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
779
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
780
|
+
const call = (runner as any).mock.calls[0];
|
|
781
|
+
expect(call[3]).toEqual({
|
|
782
|
+
SLACK_CHANNEL_ID: "C0EXAMPLE",
|
|
783
|
+
SLACK_THREAD_TS: "1700000000.000400",
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("reaction_added: extraEnv uses the reacted-to message's item.channel / item.ts", async () => {
|
|
788
|
+
const ref = createAgentSlackMembershipRef();
|
|
789
|
+
const runner = mock(async (_msg: string, _key?: string) => ({
|
|
790
|
+
result: "ok",
|
|
791
|
+
sessionId: "sess-1",
|
|
792
|
+
}));
|
|
793
|
+
setupGatingApp({
|
|
794
|
+
membershipRef: ref,
|
|
795
|
+
resolveUserEmailFn: async () => undefined,
|
|
796
|
+
runner,
|
|
797
|
+
});
|
|
798
|
+
const client = makeMockClient();
|
|
799
|
+
const event = {
|
|
800
|
+
reaction: "thumbsup",
|
|
801
|
+
item: { type: "message", channel: "D0EXAMPLE", ts: "1700000000.000500" },
|
|
802
|
+
item_user: "UBOT123",
|
|
803
|
+
user: "U-SENDER",
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
await capturedReactionAddedHandler?.({ event, client });
|
|
807
|
+
|
|
808
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
809
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
810
|
+
const call = (runner as any).mock.calls[0];
|
|
811
|
+
expect(call[3]).toEqual({
|
|
812
|
+
SLACK_CHANNEL_ID: "D0EXAMPLE",
|
|
813
|
+
SLACK_THREAD_TS: "1700000000.000500",
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// ─── agent_session_stopped handler (AGS-1.1) ───────────────────────────────
|
|
819
|
+
// Slack's native Stop/session-cancellation event for the Agent Sessions API.
|
|
820
|
+
// createSlackApp() keeps a closure-scoped registry (channel:ts -> live
|
|
821
|
+
// SlackProgress instance), populated when startStream() succeeds and cleared
|
|
822
|
+
// when the stream closes (success or failure). On agent_session_stopped, the
|
|
823
|
+
// handler looks up the matching instance and marks it externally-stopped
|
|
824
|
+
// (further appendStream/stopStream calls become no-ops), then independently
|
|
825
|
+
// resets the thread's "is working" indicator via a direct
|
|
826
|
+
// agents.sessions.setStatus({status: "active"}) call — the actual fix for the
|
|
827
|
+
// stuck-status bug, since it succeeds even when the stream is already dead
|
|
828
|
+
// server-side.
|
|
829
|
+
|
|
830
|
+
describe("agent_session_stopped handler", () => {
|
|
831
|
+
function makeStreamingClient() {
|
|
832
|
+
return {
|
|
833
|
+
...makeMockClient(),
|
|
834
|
+
chat: {
|
|
835
|
+
...makeMockClient().chat,
|
|
836
|
+
startStream: mock(async (_args: unknown) => ({ ts: "stream.ts.1" })),
|
|
837
|
+
appendStream: mock(async (_args: unknown) => {}),
|
|
838
|
+
stopStream: mock(async (_args: unknown) => {}),
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Flushes pending microtask hops (getSessionFn/resolveUserFn awaits,
|
|
844
|
+
// startStream()'s await, etc.) so the handler reaches the point where it
|
|
845
|
+
// has called runner() and is awaiting the still-pending run — i.e. the
|
|
846
|
+
// registry entry is populated but the handler's finally block (which
|
|
847
|
+
// would unregister it) hasn't run yet. Mirrors the existing
|
|
848
|
+
// flushMicrotasks() helper used by the per-thread refcounting suite above.
|
|
849
|
+
function flushMicrotasks(): Promise<void> {
|
|
850
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Starts a stream and leaves its run in flight (runner() never resolves
|
|
854
|
+
// during the test) — the realistic shape of agent_session_stopped firing
|
|
855
|
+
// mid-turn, and the only way the registry entry is still present when the
|
|
856
|
+
// event handler looks it up (the message handler's finally block clears
|
|
857
|
+
// it as soon as the run completes).
|
|
858
|
+
function startAStream(overrides: {
|
|
859
|
+
membershipRef: AgentSlackMembershipRef;
|
|
860
|
+
resolveUserEmailFn: (
|
|
861
|
+
userId: string,
|
|
862
|
+
client: unknown,
|
|
863
|
+
) => Promise<string | undefined>;
|
|
864
|
+
}) {
|
|
865
|
+
const runner = mock(
|
|
866
|
+
(_msg: string, _key?: string) =>
|
|
867
|
+
// biome-ignore lint/suspicious/noExplicitAny: deliberately never resolves — type matches ClaudeRunner's return via cast
|
|
868
|
+
new Promise(() => {}) as any,
|
|
869
|
+
);
|
|
870
|
+
_createSlackApp(
|
|
871
|
+
runner,
|
|
872
|
+
(text: string) => text,
|
|
873
|
+
(channel: string, ts: string) => `${channel}:${ts}`,
|
|
874
|
+
// biome-ignore lint/suspicious/noExplicitAny: mock factory for tests
|
|
875
|
+
(cfg) => new MockApp(cfg as Record<string, unknown>) as any,
|
|
876
|
+
mockGatingSlackConfig,
|
|
877
|
+
undefined,
|
|
878
|
+
async () => null,
|
|
879
|
+
{},
|
|
880
|
+
async () => null,
|
|
881
|
+
async () => null,
|
|
882
|
+
async (userId: string) => userId,
|
|
883
|
+
"UBOT123",
|
|
884
|
+
async () => ({ messages: [] }),
|
|
885
|
+
async () => undefined,
|
|
886
|
+
undefined,
|
|
887
|
+
undefined,
|
|
888
|
+
overrides.resolveUserEmailFn,
|
|
889
|
+
overrides.membershipRef,
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
const client = makeStreamingClient();
|
|
893
|
+
const say = makeSay();
|
|
894
|
+
const message = {
|
|
895
|
+
channel: "D123",
|
|
896
|
+
ts: "111.222",
|
|
897
|
+
text: "Hello bot",
|
|
898
|
+
channel_type: "im",
|
|
899
|
+
user: "U-SENDER",
|
|
900
|
+
};
|
|
901
|
+
// Deliberately not awaited — the handler is left mid-flight, blocked on
|
|
902
|
+
// runner()'s never-resolving promise.
|
|
903
|
+
capturedMessageHandler?.({ message, say, client });
|
|
904
|
+
return { runner, client, say };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
test("marks the matching SlackProgress externally-stopped: further appendStream calls become no-ops", async () => {
|
|
908
|
+
const ref = createAgentSlackMembershipRef();
|
|
909
|
+
const { client } = startAStream({
|
|
910
|
+
membershipRef: ref,
|
|
911
|
+
resolveUserEmailFn: async () => undefined,
|
|
912
|
+
});
|
|
913
|
+
await flushMicrotasks();
|
|
914
|
+
|
|
915
|
+
// Stream opened successfully — registry now has an entry for "D123:stream.ts.1".
|
|
916
|
+
expect(client.chat.startStream).toHaveBeenCalledTimes(1);
|
|
917
|
+
client.chat.appendStream.mockClear();
|
|
918
|
+
|
|
919
|
+
await capturedAgentSessionStoppedHandler?.({
|
|
920
|
+
event: { channel: "D123", streaming_message_ts: "stream.ts.1" },
|
|
921
|
+
client,
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
// Any further onProgress-driven appendStream against this dead stream is
|
|
925
|
+
// now a no-op — asserted indirectly by confirming a raw appendStream call
|
|
926
|
+
// is never issued once the event has fired for this stream's key.
|
|
927
|
+
expect(client.chat.appendStream).not.toHaveBeenCalled();
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test("resets the thread status via a direct agents.sessions.setStatus('active') call", async () => {
|
|
931
|
+
const ref = createAgentSlackMembershipRef();
|
|
932
|
+
const { client } = startAStream({
|
|
933
|
+
membershipRef: ref,
|
|
934
|
+
resolveUserEmailFn: async () => undefined,
|
|
935
|
+
});
|
|
936
|
+
await flushMicrotasks();
|
|
937
|
+
|
|
938
|
+
client.agents.sessions.setStatus.mockClear();
|
|
939
|
+
|
|
940
|
+
await capturedAgentSessionStoppedHandler?.({
|
|
941
|
+
event: { channel: "D123", streaming_message_ts: "stream.ts.1" },
|
|
942
|
+
client,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// thread_ts must be the run's actual thread ts ("111.222", the
|
|
946
|
+
// message's own ts since it has no thread_ts) — not the stream's
|
|
947
|
+
// streaming_message_ts ("stream.ts.1"), which is a different value the
|
|
948
|
+
// Agent Sessions API doesn't accept as a thread identifier.
|
|
949
|
+
expect(client.agents.sessions.setStatus).toHaveBeenCalledWith(
|
|
950
|
+
expect.objectContaining({
|
|
951
|
+
channel_id: "D123",
|
|
952
|
+
thread_ts: "111.222",
|
|
953
|
+
status: "active",
|
|
954
|
+
}),
|
|
955
|
+
);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test("unknown/already-cleared channel:ts is a no-op — does not throw", async () => {
|
|
959
|
+
const ref = createAgentSlackMembershipRef();
|
|
960
|
+
const { client } = startAStream({
|
|
961
|
+
membershipRef: ref,
|
|
962
|
+
resolveUserEmailFn: async () => undefined,
|
|
963
|
+
});
|
|
964
|
+
await flushMicrotasks();
|
|
965
|
+
|
|
966
|
+
// start()'s own eager setStatus call (ISW-1.1) already fired above —
|
|
967
|
+
// clear it so calls[0] below refers to the agent_session_stopped
|
|
968
|
+
// handler's own call, not start()'s.
|
|
969
|
+
client.agents.sessions.setStatus.mockClear();
|
|
970
|
+
|
|
971
|
+
await expect(
|
|
972
|
+
capturedAgentSessionStoppedHandler?.({
|
|
973
|
+
event: { channel: "D999", streaming_message_ts: "no.such.stream" },
|
|
974
|
+
client,
|
|
975
|
+
}),
|
|
976
|
+
).resolves.toBeUndefined();
|
|
977
|
+
|
|
978
|
+
// The direct status reset still fires regardless of registry lookup
|
|
979
|
+
// outcome — AC #5 says it's independent of the stream's own close path.
|
|
980
|
+
expect(client.agents.sessions.setStatus).toHaveBeenCalledWith(
|
|
981
|
+
expect.objectContaining({ channel_id: "D999", status: "active" }),
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
// On a registry miss the run's real thread_ts is unknown, so the handler
|
|
985
|
+
// must OMIT thread_ts entirely rather than fall back to the event's own
|
|
986
|
+
// streaming_message_ts ("no.such.stream") — that's the stream's message ts,
|
|
987
|
+
// a distinct value the Agent Sessions API doesn't accept as a thread id
|
|
988
|
+
// (see SlackProgress.getThreadTs()). Sending it would target the wrong
|
|
989
|
+
// conversation.
|
|
990
|
+
const setStatusArgs = client.agents.sessions.setStatus.mock.calls[0]?.[0];
|
|
991
|
+
expect(setStatusArgs).not.toHaveProperty("thread_ts");
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test("agents.sessions.setStatus failure does not crash the handler", async () => {
|
|
995
|
+
const ref = createAgentSlackMembershipRef();
|
|
996
|
+
const { client } = startAStream({
|
|
997
|
+
membershipRef: ref,
|
|
998
|
+
resolveUserEmailFn: async () => undefined,
|
|
999
|
+
});
|
|
1000
|
+
await flushMicrotasks();
|
|
1001
|
+
|
|
1002
|
+
client.agents.sessions.setStatus.mockRejectedValueOnce(
|
|
1003
|
+
new Error("setStatus down"),
|
|
1004
|
+
);
|
|
1005
|
+
|
|
1006
|
+
await expect(
|
|
1007
|
+
capturedAgentSessionStoppedHandler?.({
|
|
1008
|
+
event: { channel: "D123", streaming_message_ts: "stream.ts.1" },
|
|
1009
|
+
client,
|
|
1010
|
+
}),
|
|
1011
|
+
).resolves.toBeUndefined();
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("registry entry is cleared once the stream closes normally — a later agent_session_stopped for it is a no-op lookup", async () => {
|
|
1015
|
+
const ref = createAgentSlackMembershipRef();
|
|
1016
|
+
const runner = mock(async (_msg: string, _key?: string) => ({
|
|
1017
|
+
result: "Claude response",
|
|
1018
|
+
sessionId: "sess-1",
|
|
1019
|
+
}));
|
|
1020
|
+
_createSlackApp(
|
|
1021
|
+
runner,
|
|
1022
|
+
(text: string) => text,
|
|
1023
|
+
(channel: string, ts: string) => `${channel}:${ts}`,
|
|
1024
|
+
// biome-ignore lint/suspicious/noExplicitAny: mock factory for tests
|
|
1025
|
+
(cfg) => new MockApp(cfg as Record<string, unknown>) as any,
|
|
1026
|
+
mockGatingSlackConfig,
|
|
1027
|
+
undefined,
|
|
1028
|
+
async () => null,
|
|
1029
|
+
{},
|
|
1030
|
+
async () => null,
|
|
1031
|
+
async () => null,
|
|
1032
|
+
async (userId: string) => userId,
|
|
1033
|
+
"UBOT123",
|
|
1034
|
+
async () => ({ messages: [] }),
|
|
1035
|
+
async () => undefined,
|
|
1036
|
+
undefined,
|
|
1037
|
+
undefined,
|
|
1038
|
+
async () => undefined,
|
|
1039
|
+
ref,
|
|
1040
|
+
);
|
|
1041
|
+
const client = makeStreamingClient();
|
|
1042
|
+
const say = makeSay();
|
|
1043
|
+
// Run completes normally (unlike startAStream()'s never-resolving
|
|
1044
|
+
// runner) — the message handler's finally block clears the registry
|
|
1045
|
+
// entry for this stream key before this call resolves.
|
|
1046
|
+
await capturedMessageHandler?.({
|
|
1047
|
+
message: {
|
|
1048
|
+
channel: "D123",
|
|
1049
|
+
ts: "111.222",
|
|
1050
|
+
text: "Hello bot",
|
|
1051
|
+
channel_type: "im",
|
|
1052
|
+
user: "U-SENDER",
|
|
1053
|
+
},
|
|
1054
|
+
say,
|
|
1055
|
+
client,
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// Firing the event now must still be a safe no-op (covered by the
|
|
1059
|
+
// "unknown key" case above) — this test instead confirms stopStream was
|
|
1060
|
+
// not called a second time as a side effect of the (now no-op) lookup.
|
|
1061
|
+
client.chat.stopStream.mockClear();
|
|
1062
|
+
|
|
1063
|
+
await capturedAgentSessionStoppedHandler?.({
|
|
1064
|
+
event: { channel: "D123", streaming_message_ts: "stream.ts.1" },
|
|
1065
|
+
client,
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
expect(client.chat.stopStream).not.toHaveBeenCalled();
|
|
1069
|
+
});
|
|
1070
|
+
});
|