@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,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration coverage for the Slack chat conversation journey (T-027).
|
|
3
|
+
*
|
|
4
|
+
* Scope note: the task description's "chat-service thread -> agent claims"
|
|
5
|
+
* phrasing does not correspond to any real code path in this repo —
|
|
6
|
+
* createSlackApp() (agent/src/runtime/slack.ts) never touches ChatServiceClient, and
|
|
7
|
+
* chat-poller.ts (the chat-service flow) never touches Slack/Bolt/WebClient.
|
|
8
|
+
* This suite instead exercises the REAL, buildable journey: a multi-turn
|
|
9
|
+
* Slack conversation flowing end-to-end through createSlackApp — incoming
|
|
10
|
+
* Bolt message/app_mention event -> injected Claude runner -> reply posted
|
|
11
|
+
* via the mocked WebClient (say / client.chat.postMessage) -- verifying
|
|
12
|
+
* session continuity across turns via sessions.ts's threadKey +
|
|
13
|
+
* getSessionFn wiring.
|
|
14
|
+
*
|
|
15
|
+
* Strategy mirrors slack.integration.test.ts: inject all dependencies via
|
|
16
|
+
* createSlackApp's params (MockApp Bolt factory + mocked WebClient-shaped
|
|
17
|
+
* client). No mock.module(), no real Bolt socket, no real HTTP.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
21
|
+
import { createAgentSlackMembershipRef } from "./agent-slack-membership-ref.ts";
|
|
22
|
+
import type { ModelUsage, TokenUsage } from "./claude.ts";
|
|
23
|
+
import { threadKey } from "./sessions.ts";
|
|
24
|
+
import { createSlackApp as _createSlackApp } from "./slack.ts";
|
|
25
|
+
|
|
26
|
+
// ─── Mock runner ────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const mockRunClaude = mock(
|
|
29
|
+
async (
|
|
30
|
+
_msg: string,
|
|
31
|
+
_key?: string,
|
|
32
|
+
): Promise<{
|
|
33
|
+
result: string;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
usage?: TokenUsage;
|
|
36
|
+
totalCostUsd?: number;
|
|
37
|
+
modelUsage?: ModelUsage;
|
|
38
|
+
}> => ({ result: "Claude response text" }),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const identityFormatter = (text: string): string => text;
|
|
42
|
+
|
|
43
|
+
// ─── Test slack config ──────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const mockSlackConfig = {
|
|
46
|
+
botToken: "xoxb-test-token",
|
|
47
|
+
appToken: "xapp-test-token",
|
|
48
|
+
signingSecret: "test-secret",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ─── MockApp — captures registered handlers (same pattern as slack.integration.test.ts) ───
|
|
52
|
+
|
|
53
|
+
type HandlerFn = (...args: unknown[]) => Promise<void>;
|
|
54
|
+
|
|
55
|
+
let capturedMessageHandler: HandlerFn | null = null;
|
|
56
|
+
let capturedMentionHandler: HandlerFn | null = null;
|
|
57
|
+
|
|
58
|
+
class MockApp {
|
|
59
|
+
constructor(_args: Record<string, unknown>) {
|
|
60
|
+
capturedMessageHandler = null;
|
|
61
|
+
capturedMentionHandler = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
message(handler: HandlerFn) {
|
|
65
|
+
capturedMessageHandler = handler;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
event(type: string, handler: HandlerFn) {
|
|
69
|
+
if (type === "app_mention") capturedMentionHandler = handler;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Wrap with test deps — mirrors the createSlackApp() wrapper in
|
|
74
|
+
// slack.integration.test.ts, but only exposes the params this suite needs.
|
|
75
|
+
function createSlackApp(
|
|
76
|
+
getSessionFn: (key: string) => Promise<string | undefined>,
|
|
77
|
+
) {
|
|
78
|
+
return _createSlackApp(
|
|
79
|
+
mockRunClaude,
|
|
80
|
+
identityFormatter,
|
|
81
|
+
threadKey,
|
|
82
|
+
// biome-ignore lint/suspicious/noExplicitAny: mock factory for tests
|
|
83
|
+
(cfg) => new MockApp(cfg as Record<string, unknown>) as any,
|
|
84
|
+
mockSlackConfig,
|
|
85
|
+
undefined, // sentryClient
|
|
86
|
+
async () => null, // fileDownloaderFn
|
|
87
|
+
{}, // voiceConfig
|
|
88
|
+
async () => null, // transcribeAudioFn
|
|
89
|
+
async () => null, // synthesizeSpeechFn
|
|
90
|
+
async (userId: string) => userId, // resolveUserFn
|
|
91
|
+
"UBOT123", // botUserId
|
|
92
|
+
async () => ({ messages: [] }), // conversationsRepliesFn
|
|
93
|
+
getSessionFn,
|
|
94
|
+
undefined, // blocksConverter — default
|
|
95
|
+
undefined, // chatTokenReporter — default noop
|
|
96
|
+
async () => undefined, // resolveUserEmailFn
|
|
97
|
+
// A fresh, unsynced ref — never the process-wide agentSlackMembershipRef
|
|
98
|
+
// singleton, which agent-slack-membership-ref.unit.test.ts mutates
|
|
99
|
+
// directly (see slack.integration.test.ts's identical fix).
|
|
100
|
+
createAgentSlackMembershipRef(),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Mock client helpers (same shape as slack.integration.test.ts) ─────────
|
|
105
|
+
|
|
106
|
+
function makeMockClient() {
|
|
107
|
+
return {
|
|
108
|
+
agents: {
|
|
109
|
+
sessions: {
|
|
110
|
+
setStatus: mock(async (_args: unknown) => {}),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
chat: {
|
|
114
|
+
postMessage: mock(async (_args: unknown) => ({ ts: "posted.ts.1" })),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function makeSay(replyTs = "reply.ts.1") {
|
|
120
|
+
return mock(async (_args: unknown) => ({ ts: replyTs }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe("Slack chat conversation journey — DM multi-turn continuity", () => {
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
mockRunClaude.mockClear();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("turn 1 (no prior session) then turn 2 (established session) both reach Claude and both post replies, threaded on the same sessionKey", async () => {
|
|
129
|
+
// Turn 1: brand-new DM — no session established yet.
|
|
130
|
+
const noSession = mock(async (_key: string) => undefined);
|
|
131
|
+
createSlackApp(noSession);
|
|
132
|
+
|
|
133
|
+
const client1 = makeMockClient();
|
|
134
|
+
const say1 = makeSay("reply.ts.turn1");
|
|
135
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
136
|
+
result: "Hi! How can I help?",
|
|
137
|
+
sessionId: "sess-established-1",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await capturedMessageHandler?.({
|
|
141
|
+
message: {
|
|
142
|
+
channel: "D100",
|
|
143
|
+
ts: "1000.001",
|
|
144
|
+
text: "Hey, can you look into the failing build?",
|
|
145
|
+
channel_type: "im",
|
|
146
|
+
user: "U1",
|
|
147
|
+
},
|
|
148
|
+
say: say1,
|
|
149
|
+
client: client1,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Turn 1 assertions: runner invoked with the DM's own sessionKey (thread
|
|
153
|
+
// root = channel:ts, since no thread_ts on the first message), reply posted.
|
|
154
|
+
expect(mockRunClaude).toHaveBeenCalledTimes(1);
|
|
155
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
156
|
+
"[U1]: Hey, can you look into the failing build?",
|
|
157
|
+
"D100:1000.001",
|
|
158
|
+
expect.any(Function),
|
|
159
|
+
{ SLACK_CHANNEL_ID: "D100", SLACK_THREAD_TS: "1000.001" },
|
|
160
|
+
);
|
|
161
|
+
expect(say1).toHaveBeenCalledWith({
|
|
162
|
+
text: "Hi! How can I help?",
|
|
163
|
+
thread_ts: "1000.001",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Turn 2: a follow-up DM in the SAME channel/thread. Simulate that the
|
|
167
|
+
// session from turn 1 is now "established" — getSessionFn now returns the
|
|
168
|
+
// fixed session id for this thread's key, proving continuity is driven by
|
|
169
|
+
// the injected session lookup, not implicit in-process state.
|
|
170
|
+
const establishedKey = threadKey("D100", "1000.001");
|
|
171
|
+
const establishedSession = mock(async (key: string) =>
|
|
172
|
+
key === establishedKey ? "sess-established-1" : undefined,
|
|
173
|
+
);
|
|
174
|
+
createSlackApp(establishedSession);
|
|
175
|
+
|
|
176
|
+
const client2 = makeMockClient();
|
|
177
|
+
const say2 = makeSay("reply.ts.turn2");
|
|
178
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
179
|
+
result: "Found it — the linter step is timing out.",
|
|
180
|
+
sessionId: "sess-established-1",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await capturedMessageHandler?.({
|
|
184
|
+
message: {
|
|
185
|
+
channel: "D100",
|
|
186
|
+
ts: "1000.002",
|
|
187
|
+
thread_ts: "1000.001", // Slack threads DM follow-ups under the root ts
|
|
188
|
+
text: "Any update?",
|
|
189
|
+
channel_type: "im",
|
|
190
|
+
user: "U1",
|
|
191
|
+
},
|
|
192
|
+
say: say2,
|
|
193
|
+
client: client2,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Turn 2 assertions: runner invoked with the SAME sessionKey as turn 1
|
|
197
|
+
// (continuity), and the reply is posted back threaded on the same root ts.
|
|
198
|
+
expect(mockRunClaude).toHaveBeenCalledTimes(2);
|
|
199
|
+
expect(mockRunClaude).toHaveBeenLastCalledWith(
|
|
200
|
+
"[U1]: Any update?",
|
|
201
|
+
establishedKey,
|
|
202
|
+
expect.any(Function),
|
|
203
|
+
{ SLACK_CHANNEL_ID: "D100", SLACK_THREAD_TS: "1000.001" },
|
|
204
|
+
);
|
|
205
|
+
expect(say2).toHaveBeenCalledWith({
|
|
206
|
+
text: "Found it — the linter step is timing out.",
|
|
207
|
+
thread_ts: "1000.001",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Sanity: if session continuity were broken (e.g. sessionKey recomputed
|
|
211
|
+
// per-turn without reference to the established thread root), turn 2's
|
|
212
|
+
// sessionKey would differ from turn 1's. Confirm they match exactly.
|
|
213
|
+
const turn1SessionKey = mockRunClaude.mock.calls[0][1];
|
|
214
|
+
const turn2SessionKey = mockRunClaude.mock.calls[1][1];
|
|
215
|
+
expect(turn2SessionKey).toBe(turn1SessionKey);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("Slack chat conversation journey — channel app_mention thread continuity", () => {
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
mockRunClaude.mockClear();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("initial @mention (no session) then a plain follow-up message in the same thread (established session) both reach Claude on the mention's threadKey", async () => {
|
|
225
|
+
// Turn 1: an @mention in a channel starts a new thread — no session yet.
|
|
226
|
+
const noSession = mock(async (_key: string) => undefined);
|
|
227
|
+
createSlackApp(noSession);
|
|
228
|
+
|
|
229
|
+
const client1 = makeMockClient();
|
|
230
|
+
const say1 = makeSay("mention.reply.ts");
|
|
231
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
232
|
+
result: "On it — checking the deploy logs.",
|
|
233
|
+
sessionId: "sess-thread-77",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await capturedMentionHandler?.({
|
|
237
|
+
event: {
|
|
238
|
+
text: "<@UBOT123> can you check the deploy?",
|
|
239
|
+
channel: "C500",
|
|
240
|
+
ts: "2000.001",
|
|
241
|
+
user: "U2",
|
|
242
|
+
},
|
|
243
|
+
say: say1,
|
|
244
|
+
client: client1,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const mentionKey = threadKey("C500", "2000.001");
|
|
248
|
+
expect(mockRunClaude).toHaveBeenCalledTimes(1);
|
|
249
|
+
expect(mockRunClaude).toHaveBeenCalledWith(
|
|
250
|
+
"[U2]: <@UBOT123> can you check the deploy?",
|
|
251
|
+
mentionKey,
|
|
252
|
+
expect.any(Function),
|
|
253
|
+
{ SLACK_CHANNEL_ID: "C500", SLACK_THREAD_TS: "2000.001" },
|
|
254
|
+
);
|
|
255
|
+
expect(say1).toHaveBeenCalledWith({
|
|
256
|
+
text: "On it — checking the deploy logs.",
|
|
257
|
+
thread_ts: "2000.001",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Turn 2: a plain (non-@mention) follow-up posted inside that same thread.
|
|
261
|
+
// The channel message handler only routes thread replies when a session
|
|
262
|
+
// is already established for that thread — simulate turn 1 having
|
|
263
|
+
// established it, via getSessionFn now resolving the mention's threadKey.
|
|
264
|
+
const establishedSession = mock(async (key: string) =>
|
|
265
|
+
key === mentionKey ? "sess-thread-77" : undefined,
|
|
266
|
+
);
|
|
267
|
+
createSlackApp(establishedSession);
|
|
268
|
+
|
|
269
|
+
const client2 = makeMockClient();
|
|
270
|
+
const say2 = makeSay("followup.reply.ts");
|
|
271
|
+
mockRunClaude.mockResolvedValueOnce({
|
|
272
|
+
result: "Deploy looks healthy now.",
|
|
273
|
+
sessionId: "sess-thread-77",
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await capturedMessageHandler?.({
|
|
277
|
+
message: {
|
|
278
|
+
channel: "C500",
|
|
279
|
+
ts: "2000.002",
|
|
280
|
+
thread_ts: "2000.001",
|
|
281
|
+
text: "thanks, any update?",
|
|
282
|
+
channel_type: "channel",
|
|
283
|
+
user: "U2",
|
|
284
|
+
},
|
|
285
|
+
say: say2,
|
|
286
|
+
client: client2,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(mockRunClaude).toHaveBeenCalledTimes(2);
|
|
290
|
+
expect(mockRunClaude).toHaveBeenLastCalledWith(
|
|
291
|
+
"[Thread message — respond normally, or use [silent] if no response is needed]\n[U2]: thanks, any update?",
|
|
292
|
+
mentionKey,
|
|
293
|
+
expect.any(Function),
|
|
294
|
+
{ SLACK_CHANNEL_ID: "C500", SLACK_THREAD_TS: "2000.001" },
|
|
295
|
+
);
|
|
296
|
+
expect(say2).toHaveBeenCalledWith({
|
|
297
|
+
text: "Deploy looks healthy now.",
|
|
298
|
+
thread_ts: "2000.001",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Continuity check: both turns keyed identically off the mention's thread root.
|
|
302
|
+
const turn1SessionKey = mockRunClaude.mock.calls[0][1];
|
|
303
|
+
const turn2SessionKey = mockRunClaude.mock.calls[1][1];
|
|
304
|
+
expect(turn2SessionKey).toBe(turn1SessionKey);
|
|
305
|
+
expect(turn2SessionKey).toBe(mentionKey);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("a follow-up @mention in an already-established thread is dropped — the channel message handler owns it, avoiding a double response", async () => {
|
|
309
|
+
// Once a thread has a session, a redundant @mention on a later message in
|
|
310
|
+
// that same thread must NOT also trigger a second Claude call/reply —
|
|
311
|
+
// the message handler already covers thread continuity.
|
|
312
|
+
const establishedSession = mock(async (_key: string) => "sess-thread-77");
|
|
313
|
+
createSlackApp(establishedSession);
|
|
314
|
+
|
|
315
|
+
const client = makeMockClient();
|
|
316
|
+
const say = makeSay();
|
|
317
|
+
|
|
318
|
+
await capturedMentionHandler?.({
|
|
319
|
+
event: {
|
|
320
|
+
text: "<@UBOT123> one more thing",
|
|
321
|
+
channel: "C500",
|
|
322
|
+
ts: "2000.003",
|
|
323
|
+
thread_ts: "2000.001",
|
|
324
|
+
user: "U2",
|
|
325
|
+
},
|
|
326
|
+
say,
|
|
327
|
+
client,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(mockRunClaude).not.toHaveBeenCalled();
|
|
331
|
+
expect(say).not.toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
});
|