@desplega.ai/agent-swarm 1.152.0 → 1.152.2
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/README.md +3 -0
- package/dist/{acp-adapter-d05sg0q4.js → acp-adapter-pfeje2v5.js} +1 -1
- package/dist/{actions-4sc0v342.js → actions-0sttc7f5.js} +12 -12
- package/dist/{app-2d0v6kt6.js → app-p8teva95.js} +3 -3
- package/dist/{assistant-zs6wdnrf.js → assistant-r45jnsrt.js} +15 -15
- package/dist/{boot-reembed-vhh33520.js → boot-reembed-1qmybsd4.js} +4 -4
- package/dist/{boot-reembed-3dwdnnsr.js → boot-reembed-je26h5y7.js} +3 -3
- package/dist/{boot-scrub-logs-e1fnabcg.js → boot-scrub-logs-gte3h1mm.js} +2 -2
- package/dist/{claude-adapter-h58xqzzd.js → claude-adapter-csc1mzka.js} +3 -3
- package/dist/{claude-sdk-session-pbaczbgt.js → claude-sdk-session-fcd6f078.js} +3 -3
- package/dist/{cli-zdcdh02m.js → cli-2nnd56zt.js} +1 -1
- package/dist/{cli-awb59f08.js → cli-3bpzj8me.js} +2 -2
- package/dist/{cli-f1f9n77e.js → cli-4ahpr4sw.js} +1 -1
- package/dist/{cli-cs9z1hw3.js → cli-56h18ng2.js} +1 -1
- package/dist/{cli-5bqt4gwa.js → cli-b9dd0h3k.js} +2 -2
- package/dist/{cli-5p007m2q.js → cli-ckz5t2hb.js} +7 -7
- package/dist/{cli-p2hx1pxm.js → cli-fmy65jdn.js} +3 -3
- package/dist/{cli-fhdssm2v.js → cli-gphgnvpf.js} +2 -2
- package/dist/{cli-nd5s3m4y.js → cli-gpqcs935.js} +2 -2
- package/dist/{cli-wp7cejrg.js → cli-jbmc0r7r.js} +1 -1
- package/dist/{cli-evgy5w3y.js → cli-jew3jda5.js} +1 -1
- package/dist/{cli-4fqn2vpg.js → cli-jvy9qgjs.js} +12 -10
- package/dist/{cli-t1mykkng.js → cli-qd65dvr6.js} +8 -8
- package/dist/{cli-cwvey7n1.js → cli-qn042eh7.js} +8 -8
- package/dist/{cli-t6axtdcg.js → cli-r84ctdxn.js} +1 -1
- package/dist/{cli-6k2athpf.js → cli-wpv5rgg2.js} +2 -2
- package/dist/{cli-8fy9dmpv.js → cli-xw7vak96.js} +2 -2
- package/dist/{cli-87hdfyde.js → cli-xy1cchaf.js} +1 -1
- package/dist/{cli-72vksk1n.js → cli-z3wkng35.js} +18 -18
- package/dist/{cli-gacq5hg8.js → cli-zvgj5zg8.js} +1 -1
- package/dist/{cli-jh9txj4n.js → cli-zvkq5csn.js} +1 -1
- package/dist/cli.js +11 -11
- package/dist/{codex-adapter-v3cz462q.js → codex-adapter-fxmpsabw.js} +2 -2
- package/dist/{codex-session-runner-de1a5g5z.js → codex-session-runner-rr1e47ea.js} +2 -2
- package/dist/{commands-wfqqpn95.js → commands-0f5rxz51.js} +2 -2
- package/dist/{db-9zzv5y05.js → db-r3jxf3wy.js} +2 -2
- package/dist/{dispatcher-hfm1jbgx.js → dispatcher-06vph6q4.js} +11 -11
- package/dist/{handlers-wrrhsack.js → handlers-0hgtc910.js} +14 -14
- package/dist/{hook-9skye8zd.js → hook-9kvrv1er.js} +1 -1
- package/dist/{hook-fgjv62k5.js → hook-fbfhkat3.js} +2 -2
- package/dist/{http-7grttjfq.js → http-xbyx8k5j.js} +90 -29
- package/dist/{index-pfhzrh1d.js → index-gpfn1741.js} +3 -3
- package/dist/{index-29gxx9ar.js → index-jsw3aynb.js} +11 -11
- package/dist/{index-30pz2h9w.js → index-rnyahfn5.js} +11 -11
- package/dist/{index-4ek1df87.js → index-sgk50mbg.js} +12 -12
- package/dist/{index-myfwqqyq.js → index-tfsdxyv6.js} +7 -7
- package/dist/{keepalive-j75m2m7k.js → keepalive-s3xpr7sw.js} +4 -4
- package/dist/{lead-a1hpjt0a.js → lead-rq4n5r16.js} +15 -15
- package/dist/{maintenance-tngjzqdq.js → maintenance-f0atj1ab.js} +4 -4
- package/dist/{oauth-refresh-sweep-em1fn044.js → oauth-refresh-sweep-q0t3bxre.js} +3 -3
- package/dist/{onboard-fv7ezvwx.js → onboard-br5j1kch.js} +2 -2
- package/dist/{otel-impl-6vbb568y.js → otel-impl-vdqge2nt.js} +1 -1
- package/dist/{pricing-refresh-nmm949an.js → pricing-refresh-hb3691fd.js} +4 -4
- package/dist/{rbac-roles-0fz5qryx.js → rbac-roles-jh280k9g.js} +3 -3
- package/dist/{rbac-roles-xtp05syf.js → rbac-roles-t78xt39b.js} +2 -2
- package/dist/{render-v2-fpb8whme.js → render-v2-7p8r2ymg.js} +3 -3
- package/dist/{seed-pricing-zwa8bs3t.js → seed-pricing-hf889enh.js} +3 -3
- package/dist/{setup-3ybwbdpd.js → setup-w6mb03jv.js} +2 -2
- package/dist/{work-objects-rmayxcx7.js → work-objects-nt8gy1kj.js} +14 -14
- package/dist/{worker-rng4jhnh.js → worker-dwy617t0.js} +15 -15
- package/openapi.json +2 -2
- package/package.json +1 -1
- package/src/agentmail/inbound-archive.ts +71 -0
- package/src/agentmail/types.ts +2 -0
- package/src/http/webhooks.ts +13 -5
- package/src/slack/assistant.ts +10 -1
- package/src/slack/handlers.ts +10 -1
- package/src/slack/thread-buffer.ts +14 -3
- package/src/tests/agentmail-inbound-archive.test.ts +418 -0
- package/src/tests/slack-assistant.test.ts +134 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { Webhook } from "svix";
|
|
5
|
+
import triage from "../../scripts/contact-inbox-triage";
|
|
6
|
+
import { initAgentMail, resetAgentMail } from "../agentmail/app";
|
|
7
|
+
import {
|
|
8
|
+
archiveInboundMessage,
|
|
9
|
+
INBOUND_ARCHIVE_TTL_MS,
|
|
10
|
+
inboundArchiveKey,
|
|
11
|
+
} from "../agentmail/inbound-archive";
|
|
12
|
+
import type { AgentMailWebhookPayload } from "../agentmail/types";
|
|
13
|
+
import { closeDb, getDbClient, getKv, initDb, upsertKv } from "../be/db";
|
|
14
|
+
import { handleWebhooks } from "../http/webhooks";
|
|
15
|
+
import { KvKeySchema, KvNamespaceSchema } from "../types";
|
|
16
|
+
|
|
17
|
+
const inbox = "desplega-contact@agent-swarm.dev";
|
|
18
|
+
const namespace = "agentmail-inbound";
|
|
19
|
+
const key = (id: string) => inboundArchiveKey(inbox, id);
|
|
20
|
+
const secret = `whsec_${Buffer.from("archive-test-secret").toString("base64")}`;
|
|
21
|
+
const savedEnv = { ...process.env };
|
|
22
|
+
function payload(id = "cold-unauthenticated"): AgentMailWebhookPayload {
|
|
23
|
+
return {
|
|
24
|
+
type: "event",
|
|
25
|
+
event_id: `event-${id}`,
|
|
26
|
+
event_type: "message.received.unauthenticated",
|
|
27
|
+
message: {
|
|
28
|
+
inbox_id: inbox,
|
|
29
|
+
message_id: id,
|
|
30
|
+
thread_id: "cold-thread",
|
|
31
|
+
organization_id: "test",
|
|
32
|
+
from_: "Alex <alex@acme.example>",
|
|
33
|
+
to: [inbox],
|
|
34
|
+
cc: [],
|
|
35
|
+
bcc: [],
|
|
36
|
+
reply_to: [],
|
|
37
|
+
subject: "Desplega pilot",
|
|
38
|
+
preview: "",
|
|
39
|
+
text: "Our team wants to evaluate Desplega.",
|
|
40
|
+
html: null,
|
|
41
|
+
labels: ["received", "unauthenticated"],
|
|
42
|
+
attachments: [],
|
|
43
|
+
in_reply_to: null,
|
|
44
|
+
references: [],
|
|
45
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
46
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
47
|
+
updated_at: "2026-01-01T00:00:00Z",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
initDb(":memory:");
|
|
54
|
+
process.env.AGENTMAIL_WEBHOOK_SECRET = secret;
|
|
55
|
+
delete process.env.AGENTMAIL_DISABLE;
|
|
56
|
+
// Routing rejects this inbox; capture must still happen.
|
|
57
|
+
process.env.AGENTMAIL_INBOX_DOMAIN_FILTER = "other.example";
|
|
58
|
+
resetAgentMail();
|
|
59
|
+
initAgentMail();
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
closeDb();
|
|
63
|
+
resetAgentMail();
|
|
64
|
+
for (const key of [
|
|
65
|
+
"AGENTMAIL_WEBHOOK_SECRET",
|
|
66
|
+
"AGENTMAIL_DISABLE",
|
|
67
|
+
"AGENTMAIL_INBOX_DOMAIN_FILTER",
|
|
68
|
+
]) {
|
|
69
|
+
if (savedEnv[key] === undefined) delete process.env[key];
|
|
70
|
+
else process.env[key] = savedEnv[key];
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
async function deliver(event: AgentMailWebhookPayload, valid = true) {
|
|
75
|
+
const body = JSON.stringify(event);
|
|
76
|
+
const timestamp = new Date();
|
|
77
|
+
const req = Readable.from([Buffer.from(body)]) as IncomingMessage;
|
|
78
|
+
req.method = "POST";
|
|
79
|
+
req.headers = {
|
|
80
|
+
"svix-id": event.event_id,
|
|
81
|
+
"svix-timestamp": String(Math.floor(timestamp.getTime() / 1000)),
|
|
82
|
+
"svix-signature": valid ? new Webhook(secret).sign(event.event_id, timestamp, body) : "invalid",
|
|
83
|
+
};
|
|
84
|
+
let status = 0;
|
|
85
|
+
let archiveAtAck: ReturnType<typeof getKv> | undefined;
|
|
86
|
+
const res = {
|
|
87
|
+
writeHead(code: number) {
|
|
88
|
+
status = code;
|
|
89
|
+
},
|
|
90
|
+
end() {
|
|
91
|
+
if (status === 200) archiveAtAck = getKv(namespace, key(event.message!.message_id));
|
|
92
|
+
},
|
|
93
|
+
} as unknown as ServerResponse;
|
|
94
|
+
await handleWebhooks(req, res, ["api", "agentmail", "webhook"]);
|
|
95
|
+
return { status, archiveAtAck: await archiveAtAck };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Real in-memory SQL/KV, only provider enrichment is stubbed. There are no list
|
|
99
|
+
// or thread methods on this context: discovering cold mail cannot depend on them.
|
|
100
|
+
function context() {
|
|
101
|
+
return {
|
|
102
|
+
swarm: {
|
|
103
|
+
async db_query({ sql, params }: { sql: string; params?: unknown[] }) {
|
|
104
|
+
const rows = await getDbClient().query<Record<string, unknown>>(sql, params);
|
|
105
|
+
return { success: true, rows: rows.map((row) => Object.values(row)) };
|
|
106
|
+
},
|
|
107
|
+
async kv_getOrNull({ namespace: ns, key }: { namespace: string; key: string }) {
|
|
108
|
+
KvNamespaceSchema.parse(ns);
|
|
109
|
+
KvKeySchema.parse(key);
|
|
110
|
+
return await getKv(ns, key);
|
|
111
|
+
},
|
|
112
|
+
async kv_set(input: Parameters<typeof upsertKv>[0] & { expiresInSec?: number }) {
|
|
113
|
+
KvNamespaceSchema.parse(input.namespace);
|
|
114
|
+
KvKeySchema.parse(input.key);
|
|
115
|
+
await upsertKv({
|
|
116
|
+
...input,
|
|
117
|
+
expiresAt: input.expiresInSec ? Date.now() + input.expiresInSec * 1000 : null,
|
|
118
|
+
});
|
|
119
|
+
return { success: true };
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
stdlib: {
|
|
123
|
+
async fetchJson() {
|
|
124
|
+
return { Status: 0, Answer: [{ type: 15, data: "10 mx.acme.example" }] };
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
api: {},
|
|
128
|
+
} as unknown as Parameters<typeof triage>[1];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe("verified inbound archive", () => {
|
|
132
|
+
test("cold unauthenticated mail is durable before ACK despite routing filter", async () => {
|
|
133
|
+
const result = await deliver(payload());
|
|
134
|
+
expect(result.status).toBe(200);
|
|
135
|
+
expect(result.archiveAtAck?.value).toMatchObject({
|
|
136
|
+
payload: {
|
|
137
|
+
event_type: payload().event_type,
|
|
138
|
+
message: { message_id: payload().message!.message_id },
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
test("stores only bounded discovery fields and scrubs credentials before truncation", async () => {
|
|
143
|
+
const event = payload();
|
|
144
|
+
const token = `ghp_${"a".repeat(36)}`;
|
|
145
|
+
Object.assign(event.message!, {
|
|
146
|
+
subject: `${token} ${"s".repeat(500)}`,
|
|
147
|
+
text: `${token} ${"t".repeat(20000)}`,
|
|
148
|
+
html: "h".repeat(20000),
|
|
149
|
+
headers: { authorization: "Bearer private-credential" },
|
|
150
|
+
extra: "unexpected-secret",
|
|
151
|
+
attachments: [
|
|
152
|
+
{
|
|
153
|
+
filename: "pitch.zip",
|
|
154
|
+
content_type: "application/zip",
|
|
155
|
+
size: 123,
|
|
156
|
+
attachment_id: "private-id",
|
|
157
|
+
content: "private-bytes",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
await archiveInboundMessage(event);
|
|
162
|
+
const entry = await getKv(namespace, key(event.message!.message_id));
|
|
163
|
+
const value = entry!.value as {
|
|
164
|
+
capturedAt: string;
|
|
165
|
+
payload: { message: Record<string, unknown> };
|
|
166
|
+
};
|
|
167
|
+
const message = value.payload.message;
|
|
168
|
+
expect(Object.keys(message).sort()).toEqual(
|
|
169
|
+
[
|
|
170
|
+
"inbox_id",
|
|
171
|
+
"message_id",
|
|
172
|
+
"thread_id",
|
|
173
|
+
"from_",
|
|
174
|
+
"reply_to",
|
|
175
|
+
"subject",
|
|
176
|
+
"text",
|
|
177
|
+
"html",
|
|
178
|
+
"timestamp",
|
|
179
|
+
"labels",
|
|
180
|
+
"attachments",
|
|
181
|
+
].sort(),
|
|
182
|
+
);
|
|
183
|
+
expect(String(message.subject)).toHaveLength(300);
|
|
184
|
+
expect(String(message.text)).toHaveLength(16000);
|
|
185
|
+
expect(String(message.html)).toHaveLength(16000);
|
|
186
|
+
expect(JSON.stringify(value)).not.toContain(token);
|
|
187
|
+
expect(JSON.stringify(value)).not.toContain("private-");
|
|
188
|
+
expect(JSON.stringify(value)).not.toContain("unexpected-secret");
|
|
189
|
+
expect(message.attachments).toEqual([
|
|
190
|
+
{ filename: "pitch.zip", content_type: "application/zip", size: 123 },
|
|
191
|
+
]);
|
|
192
|
+
expect(entry!.expiresAt).toBe(Date.parse(value.capturedAt) + INBOUND_ARCHIVE_TTL_MS);
|
|
193
|
+
const triaged = await triage({ dryRun: true }, context());
|
|
194
|
+
expect(triaged.briefs[0]?.flag).toBe("IGNORE");
|
|
195
|
+
});
|
|
196
|
+
test("live retries preserve content and expiry; expired replay starts a new window", async () => {
|
|
197
|
+
const event = payload();
|
|
198
|
+
await archiveInboundMessage(event);
|
|
199
|
+
const first = await getKv(namespace, key(event.message!.message_id));
|
|
200
|
+
event.message!.subject = "Changed retry";
|
|
201
|
+
await archiveInboundMessage(event);
|
|
202
|
+
expect(await getKv(namespace, key(event.message!.message_id))).toEqual(first);
|
|
203
|
+
await getDbClient().run("UPDATE kv_entries SET expires_at = ? WHERE namespace = ?", [
|
|
204
|
+
Date.now() - 1,
|
|
205
|
+
namespace,
|
|
206
|
+
]);
|
|
207
|
+
const expired = await triage({ dryRun: true }, context());
|
|
208
|
+
expect(expired.briefs).toHaveLength(0);
|
|
209
|
+
expect(expired.controls.countsValid).toBe(false);
|
|
210
|
+
await archiveInboundMessage(event);
|
|
211
|
+
const replay = await getKv(namespace, key(event.message!.message_id));
|
|
212
|
+
expect(replay?.value).toMatchObject({ payload: { message: { subject: "Changed retry" } } });
|
|
213
|
+
expect(replay!.expiresAt).toBeGreaterThan(Date.now());
|
|
214
|
+
});
|
|
215
|
+
test("new captures physically sweep expired archive rows", async () => {
|
|
216
|
+
await archiveInboundMessage(payload("expired"));
|
|
217
|
+
await getDbClient().run("UPDATE kv_entries SET expires_at = ? WHERE namespace = ?", [
|
|
218
|
+
Date.now() - 1,
|
|
219
|
+
namespace,
|
|
220
|
+
]);
|
|
221
|
+
await archiveInboundMessage(payload("fresh"));
|
|
222
|
+
const rows = await getDbClient().query("SELECT key FROM kv_entries WHERE namespace = ?", [
|
|
223
|
+
namespace,
|
|
224
|
+
]);
|
|
225
|
+
expect(rows).toEqual([{ key: key("fresh") }]);
|
|
226
|
+
});
|
|
227
|
+
test("invalid signatures never enter the archive", async () => {
|
|
228
|
+
expect((await deliver(payload(), false)).status).toBe(401);
|
|
229
|
+
expect(await getKv(namespace, key("cold-unauthenticated"))).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
test("storage errors return 503, and redelivery succeeds after recovery", async () => {
|
|
232
|
+
await getDbClient().run("ALTER TABLE kv_entries RENAME TO unavailable_kv");
|
|
233
|
+
expect((await deliver(payload())).status).toBe(503);
|
|
234
|
+
await getDbClient().run("ALTER TABLE unavailable_kv RENAME TO kv_entries");
|
|
235
|
+
expect((await deliver(payload())).status).toBe(200);
|
|
236
|
+
});
|
|
237
|
+
test("concurrent retries preserve one minimized message, distinct inboxes remain separate", async () => {
|
|
238
|
+
await Promise.all(Array.from({ length: 8 }, () => archiveInboundMessage(payload())));
|
|
239
|
+
const other = payload();
|
|
240
|
+
other.message!.inbox_id = "other@example.com";
|
|
241
|
+
await archiveInboundMessage(other);
|
|
242
|
+
expect(
|
|
243
|
+
await getDbClient().query("SELECT key FROM kv_entries WHERE namespace = ?", [namespace]),
|
|
244
|
+
).toHaveLength(2);
|
|
245
|
+
expect(
|
|
246
|
+
await getKv(namespace, inboundArchiveKey("other@example.com", other.message!.message_id)),
|
|
247
|
+
).not.toBeNull();
|
|
248
|
+
});
|
|
249
|
+
test("blocked and spam deliveries are captured without enabling task routing", async () => {
|
|
250
|
+
for (const type of ["message.received.blocked", "message.received.spam"] as const) {
|
|
251
|
+
const event = payload(type);
|
|
252
|
+
event.event_type = type;
|
|
253
|
+
expect((await deliver(event)).archiveAtAck).toBeTruthy();
|
|
254
|
+
}
|
|
255
|
+
const sent = payload("sent");
|
|
256
|
+
sent.event_type = "message.sent";
|
|
257
|
+
await archiveInboundMessage(sent);
|
|
258
|
+
expect(await getKv(namespace, key("sent"))).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
test("malformed inbound deliveries receive a retryable error", async () => {
|
|
261
|
+
const event = payload();
|
|
262
|
+
delete event.message;
|
|
263
|
+
expect((await deliver(event)).status).toBe(503);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("contact triage archive reader", () => {
|
|
268
|
+
test("query and marker failures stay visible and retain pending mail", async () => {
|
|
269
|
+
await archiveInboundMessage(payload());
|
|
270
|
+
const brokenQuery = context();
|
|
271
|
+
brokenQuery.swarm.db_query = async () => ({ success: false });
|
|
272
|
+
expect((await triage({}, brokenQuery)).controls.countsValid).toBe(false);
|
|
273
|
+
const brokenWrite = context();
|
|
274
|
+
brokenWrite.swarm.kv_set = async () => {
|
|
275
|
+
throw new Error("storage unavailable");
|
|
276
|
+
};
|
|
277
|
+
const result = await triage({}, brokenWrite);
|
|
278
|
+
expect(result.stats.kvWritten).toBe(0);
|
|
279
|
+
expect(result.controls.countsValid).toBe(false);
|
|
280
|
+
expect(result.errors[0]).toContain("Failed dedupe write");
|
|
281
|
+
expect((await triage({}, context())).briefs).toHaveLength(1);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("discovers cold inbound without listing endpoints and never claims historical completeness", async () => {
|
|
285
|
+
const event = payload("<cold-message@example.com>");
|
|
286
|
+
event.message!.labels = [];
|
|
287
|
+
await archiveInboundMessage(event);
|
|
288
|
+
const result = await triage({ dryRun: true }, context());
|
|
289
|
+
expect(result.briefs).toHaveLength(1);
|
|
290
|
+
expect(result.briefs[0]?.flag).toBe("NEEDS_HUMAN");
|
|
291
|
+
expect(result.briefs[0]?.from.address).toBe("alex@acme.example");
|
|
292
|
+
expect(result.controls.ok).toBe(true);
|
|
293
|
+
expect(result.stats.archiveDrained).toBe(true);
|
|
294
|
+
expect(result.stats.coverageComplete).toBe(false);
|
|
295
|
+
expect(result.stats.historicalCoverage).toBe("unknown");
|
|
296
|
+
expect(result.stats.kvWritten).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
test("bounded runs drain backlog, including old mail and multiple messages in one thread", async () => {
|
|
299
|
+
for (let i = 0; i < 27; i++)
|
|
300
|
+
await archiveInboundMessage(payload(`message-${String(i).padStart(2, "0")}`));
|
|
301
|
+
const first = await triage({ limit: 26 }, context());
|
|
302
|
+
expect(first.briefs).toHaveLength(26);
|
|
303
|
+
expect(first.stats.kvWritten).toBe(26);
|
|
304
|
+
const marker = await getKv("contact-triage-messages", key("message-00"));
|
|
305
|
+
expect(marker!.expiresAt).toBeGreaterThan(Date.now());
|
|
306
|
+
expect(marker!.expiresAt).toBeLessThanOrEqual(Date.now() + INBOUND_ARCHIVE_TTL_MS);
|
|
307
|
+
expect(first.stats.archiveDrained).toBe(false);
|
|
308
|
+
const second = await triage({}, context());
|
|
309
|
+
expect(second.briefs).toHaveLength(1);
|
|
310
|
+
expect(second.stats.archiveDrained).toBe(true);
|
|
311
|
+
const empty = await triage({}, context());
|
|
312
|
+
expect(empty.briefs).toHaveLength(0);
|
|
313
|
+
expect(empty.controls.countsValid).toBe(false);
|
|
314
|
+
expect(empty.stats.countsVoid).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
test("archive failures and missing entries cannot produce valid counts or dedupe markers", async () => {
|
|
317
|
+
await archiveInboundMessage(payload());
|
|
318
|
+
const ctx = context();
|
|
319
|
+
ctx.swarm.kv_getOrNull = async () => null;
|
|
320
|
+
const result = await triage({}, ctx);
|
|
321
|
+
expect(result.controls.ok).toBe(false);
|
|
322
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
323
|
+
expect(await getKv("contact-triage-messages", key("cold-unauthenticated"))).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("contact enrichment approval", () => {
|
|
328
|
+
async function setup() {
|
|
329
|
+
const event = payload();
|
|
330
|
+
event.message!.from_ = "Alex Rivera <private-local@acme.example>";
|
|
331
|
+
event.message!.text =
|
|
332
|
+
"Our developer team wants a Desplega pilot. https://github.com/alex-rivera?secret=private-value#private-fragment";
|
|
333
|
+
await archiveInboundMessage(event);
|
|
334
|
+
const dns = mock(async (..._args: unknown[]) => ({
|
|
335
|
+
Status: 0,
|
|
336
|
+
Answer: [{ type: 15, data: "10 mx.acme.example" }],
|
|
337
|
+
}));
|
|
338
|
+
const exa = mock(async (..._args: unknown[]) => ({ results: [] }));
|
|
339
|
+
const github = mock(async (..._args: unknown[]) => ({ data: { user: null } }));
|
|
340
|
+
const ctx = context();
|
|
341
|
+
ctx.stdlib.fetchJson = dns;
|
|
342
|
+
ctx.api = { exa: { search: exa }, ghGraphql: { graphql: github } } as unknown as typeof ctx.api;
|
|
343
|
+
return { ctx, dns, exa, github };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
test("omitted and empty approvals make zero outbound calls and retain local triage", async () => {
|
|
347
|
+
const { ctx, dns, exa, github } = await setup();
|
|
348
|
+
for (const enrichment of [undefined, {}]) {
|
|
349
|
+
const result = await triage({ dryRun: true, enrichment }, ctx);
|
|
350
|
+
expect(result.controls.ok).toBe(true);
|
|
351
|
+
expect(result.briefs[0]?.flag).toBe("NEEDS_HUMAN");
|
|
352
|
+
expect(result.specGaps.join(" ")).toContain("enrichment disabled");
|
|
353
|
+
}
|
|
354
|
+
expect(dns).not.toHaveBeenCalled();
|
|
355
|
+
expect(exa).not.toHaveBeenCalled();
|
|
356
|
+
expect(github).not.toHaveBeenCalled();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("each provider needs its own exact approval and receives only the approved identifier", async () => {
|
|
360
|
+
const { ctx, dns, exa, github } = await setup();
|
|
361
|
+
await triage({ dryRun: true, enrichment: { dnsDomains: ["acme.example"] } }, ctx);
|
|
362
|
+
expect(dns).toHaveBeenCalledTimes(1);
|
|
363
|
+
expect(exa).not.toHaveBeenCalled();
|
|
364
|
+
expect(github).not.toHaveBeenCalled();
|
|
365
|
+
expect(dns.mock.calls[0]?.[0]).toBe("https://dns.google/resolve?name=acme.example&type=MX");
|
|
366
|
+
await triage({ dryRun: true, enrichment: { exaDomains: ["acme.example"] } }, ctx);
|
|
367
|
+
expect(dns).toHaveBeenCalledTimes(1);
|
|
368
|
+
expect(exa).toHaveBeenCalledWith({
|
|
369
|
+
body: { query: "site:acme.example", numResults: 3, type: "fast" },
|
|
370
|
+
});
|
|
371
|
+
expect(github).not.toHaveBeenCalled();
|
|
372
|
+
await triage({ dryRun: true, enrichment: { githubLogins: ["alex-rivera"] } }, ctx);
|
|
373
|
+
expect(github).toHaveBeenCalledWith(expect.any(String), { login: "alex-rivera" });
|
|
374
|
+
expect(exa).toHaveBeenCalledTimes(1);
|
|
375
|
+
const sent = JSON.stringify([dns.mock.calls, exa.mock.calls, github.mock.calls]);
|
|
376
|
+
for (const privateField of [
|
|
377
|
+
"Alex Rivera",
|
|
378
|
+
"private-local",
|
|
379
|
+
"private-value",
|
|
380
|
+
"private-fragment",
|
|
381
|
+
"pilot",
|
|
382
|
+
]) {
|
|
383
|
+
expect(sent).not.toContain(privateField);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("nonmatching allowlists cannot enable calls", async () => {
|
|
388
|
+
const { ctx, dns, exa, github } = await setup();
|
|
389
|
+
await triage(
|
|
390
|
+
{
|
|
391
|
+
dryRun: true,
|
|
392
|
+
enrichment: {
|
|
393
|
+
dnsDomains: ["example" + ".org"],
|
|
394
|
+
exaDomains: ["sub.acme.example"],
|
|
395
|
+
githubLogins: ["someone-else"],
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
ctx,
|
|
399
|
+
);
|
|
400
|
+
expect(dns).not.toHaveBeenCalled();
|
|
401
|
+
expect(exa).not.toHaveBeenCalled();
|
|
402
|
+
expect(github).not.toHaveBeenCalled();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("malformed approval identifiers fail closed before provider calls", async () => {
|
|
406
|
+
const { ctx, dns, exa, github } = await setup();
|
|
407
|
+
for (const enrichment of [
|
|
408
|
+
{ dnsDomains: ["*.example"] },
|
|
409
|
+
{ exaDomains: ["acme.example secret"] },
|
|
410
|
+
{ githubLogins: ["alex-rivera?token=private"] },
|
|
411
|
+
]) {
|
|
412
|
+
await expect(triage({ dryRun: true, enrichment }, ctx)).rejects.toThrow();
|
|
413
|
+
}
|
|
414
|
+
expect(dns).not.toHaveBeenCalled();
|
|
415
|
+
expect(exa).not.toHaveBeenCalled();
|
|
416
|
+
expect(github).not.toHaveBeenCalled();
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -6,9 +6,15 @@ import {
|
|
|
6
6
|
createTaskExtended,
|
|
7
7
|
getAgentWorkingOnThread,
|
|
8
8
|
getLeadAgent,
|
|
9
|
+
getMostRecentTaskInThread,
|
|
9
10
|
getTaskById,
|
|
10
11
|
initDb,
|
|
11
12
|
} from "../be/db";
|
|
13
|
+
import { createAssistant } from "../slack/assistant";
|
|
14
|
+
import { getBufferMessageCount, instantFlush } from "../slack/thread-buffer";
|
|
15
|
+
|
|
16
|
+
/** The single function Bolt's `Assistant` stores for the `message` event — not part of its public type. */
|
|
17
|
+
type AssistantUserMessageHandler = (args: Record<string, unknown>) => Promise<void>;
|
|
12
18
|
|
|
13
19
|
process.env.SLACK_RENDER_V2 = "false";
|
|
14
20
|
|
|
@@ -121,6 +127,134 @@ describe("assistant userMessage routing — follow-up (working agent exists)", (
|
|
|
121
127
|
});
|
|
122
128
|
});
|
|
123
129
|
|
|
130
|
+
describe("assistant DM path self-mention rendering — production ingestion path", () => {
|
|
131
|
+
// Drives `createAssistant()`'s real `userMessage` handler end-to-end (the
|
|
132
|
+
// production DM path), rather than calling `rewriteSlackMentions` in
|
|
133
|
+
// isolation. It must fail if the fix at src/slack/assistant.ts (the
|
|
134
|
+
// `cachedBotUserId ?? undefined` argument to `rewriteSlackMentions`) is
|
|
135
|
+
// reverted.
|
|
136
|
+
const BOT_USER_ID = "U0PRODBOT99";
|
|
137
|
+
const OTHER_USER_ID = "U0PRODOTHR1";
|
|
138
|
+
const CHANNEL_ID = "D_PROD_DM_SELF_MENTION";
|
|
139
|
+
const THREAD_TS = "5551112223.000001";
|
|
140
|
+
|
|
141
|
+
test("resolves the bot's own mention and leaves another user's mention correct", async () => {
|
|
142
|
+
const assistant = createAssistant();
|
|
143
|
+
const userMessage = (assistant as unknown as { userMessage: AssistantUserMessageHandler[] })
|
|
144
|
+
.userMessage[0];
|
|
145
|
+
|
|
146
|
+
const client = {
|
|
147
|
+
auth: { test: async () => ({ user_id: BOT_USER_ID }) },
|
|
148
|
+
reactions: { add: async () => ({}) },
|
|
149
|
+
chat: { postMessage: async () => ({}) },
|
|
150
|
+
users: { info: async () => ({ user: undefined }) },
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const message = {
|
|
154
|
+
channel: CHANNEL_ID,
|
|
155
|
+
ts: THREAD_TS,
|
|
156
|
+
thread_ts: THREAD_TS,
|
|
157
|
+
text: `<@${BOT_USER_ID}> can you loop in <@${OTHER_USER_ID}> on this?`,
|
|
158
|
+
user: "U_PROD_REQUESTER",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
await userMessage({
|
|
162
|
+
message,
|
|
163
|
+
body: { event_id: "Ev_PROD_DM_SELF_MENTION_1" },
|
|
164
|
+
say: async () => {},
|
|
165
|
+
setStatus: async () => ({}),
|
|
166
|
+
setTitle: async () => ({}),
|
|
167
|
+
getThreadContext: async () => undefined,
|
|
168
|
+
client,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const task = await getMostRecentTaskInThread(CHANNEL_ID, THREAD_TS);
|
|
172
|
+
expect(task).toBeDefined();
|
|
173
|
+
expect(task!.task).toBe(
|
|
174
|
+
`<@${BOT_USER_ID}> (that's you) can you loop in <@${OTHER_USER_ID}> (unknown user) on this?`,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("assistant DM path self-mention rendering — buffered follow-up path (ADDITIVE_SLACK)", () => {
|
|
180
|
+
// With ADDITIVE_SLACK=true and an active agent already on the DM thread,
|
|
181
|
+
// userMessage buffers the message and returns before the direct
|
|
182
|
+
// rewriteSlackMentions(..., cachedBotUserId) call runs. The bot ID must
|
|
183
|
+
// still reach the later flush in thread-buffer.ts, or the bot's own
|
|
184
|
+
// mention renders as "(unknown user)" instead of "(that's you)".
|
|
185
|
+
// `cachedBotUserId` in assistant.ts is a module-level singleton resolved
|
|
186
|
+
// once per process, so this must reuse the ID the earlier describe block
|
|
187
|
+
// already cached via auth.test() — a fresh ID here would make this
|
|
188
|
+
// message look like it mentions "someone else" and never reach the
|
|
189
|
+
// buffered branch at all.
|
|
190
|
+
const BOT_USER_ID = "U0PRODBOT99";
|
|
191
|
+
const OTHER_USER_ID = "U0BUFOTHR01";
|
|
192
|
+
const CHANNEL_ID = "D_BUF_DM_SELF_MENTION";
|
|
193
|
+
const THREAD_TS = "5552223334.000001";
|
|
194
|
+
|
|
195
|
+
const originalAdditiveSlack = process.env.ADDITIVE_SLACK;
|
|
196
|
+
|
|
197
|
+
beforeAll(() => {
|
|
198
|
+
process.env.ADDITIVE_SLACK = "true";
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
afterAll(() => {
|
|
202
|
+
if (originalAdditiveSlack === undefined) delete process.env.ADDITIVE_SLACK;
|
|
203
|
+
else process.env.ADDITIVE_SLACK = originalAdditiveSlack;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("bot's own mention resolves through the buffered flush", async () => {
|
|
207
|
+
const worker = await createAgent({ name: "BufferedDmWorker", isLead: false, status: "idle" });
|
|
208
|
+
await createTaskExtended("original task in buffered DM thread", {
|
|
209
|
+
agentId: worker.id,
|
|
210
|
+
source: "slack",
|
|
211
|
+
slackChannelId: CHANNEL_ID,
|
|
212
|
+
slackThreadTs: THREAD_TS,
|
|
213
|
+
slackUserId: "U_BUF_REQUESTER",
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const assistant = createAssistant();
|
|
217
|
+
const userMessage = (assistant as unknown as { userMessage: AssistantUserMessageHandler[] })
|
|
218
|
+
.userMessage[0];
|
|
219
|
+
|
|
220
|
+
const client = {
|
|
221
|
+
auth: { test: async () => ({ user_id: BOT_USER_ID }) },
|
|
222
|
+
reactions: { add: async () => ({}) },
|
|
223
|
+
chat: { postMessage: async () => ({}) },
|
|
224
|
+
users: { info: async () => ({ user: undefined }) },
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const message = {
|
|
228
|
+
channel: CHANNEL_ID,
|
|
229
|
+
ts: "5552223334.000002",
|
|
230
|
+
thread_ts: THREAD_TS,
|
|
231
|
+
text: `<@${BOT_USER_ID}> can you loop in <@${OTHER_USER_ID}> on this?`,
|
|
232
|
+
user: "U_BUF_REQUESTER",
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
await userMessage({
|
|
236
|
+
message,
|
|
237
|
+
body: { event_id: "Ev_BUF_DM_SELF_MENTION_1" },
|
|
238
|
+
say: async () => {},
|
|
239
|
+
setStatus: async () => ({}),
|
|
240
|
+
setTitle: async () => ({}),
|
|
241
|
+
getThreadContext: async () => undefined,
|
|
242
|
+
client,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Buffered, not turned into a task synchronously.
|
|
246
|
+
expect(getBufferMessageCount(`${CHANNEL_ID}:${THREAD_TS}`)).toBe(1);
|
|
247
|
+
|
|
248
|
+
await instantFlush(`${CHANNEL_ID}:${THREAD_TS}`);
|
|
249
|
+
|
|
250
|
+
const flushed = await getMostRecentTaskInThread(CHANNEL_ID, THREAD_TS);
|
|
251
|
+
expect(flushed).toBeDefined();
|
|
252
|
+
expect(flushed!.task).toContain(`<@${BOT_USER_ID}> (that's you)`);
|
|
253
|
+
expect(flushed!.task).not.toContain(`<@${BOT_USER_ID}> (unknown user)`);
|
|
254
|
+
expect(flushed!.task).toContain(`<@${OTHER_USER_ID}> (unknown user)`);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
124
258
|
describe("assistant thread title truncation", () => {
|
|
125
259
|
test("short messages stay as-is", () => {
|
|
126
260
|
const text = "Check agent status";
|