@desplega.ai/agent-swarm 1.83.2 → 1.84.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/README.md +48 -8
- package/openapi.json +24 -3
- package/package.json +1 -1
- package/src/be/migrations/076_kapso_sender_user_backfill.sql +43 -0
- package/src/commands/context-preamble.ts +178 -0
- package/src/commands/runner.ts +28 -1
- package/src/http/users.ts +11 -3
- package/src/http/webhooks.ts +101 -0
- package/src/integrations/kapso/client.ts +198 -0
- package/src/integrations/kapso/config.ts +104 -0
- package/src/integrations/kapso/inbound.ts +147 -0
- package/src/prompts/base-prompt.ts +15 -2
- package/src/prompts/session-templates.ts +26 -12
- package/src/server.ts +14 -0
- package/src/tests/agentmail-sending-skill.test.ts +75 -0
- package/src/tests/agents-list-model-display.test.ts +33 -0
- package/src/tests/base-prompt.test.ts +90 -1
- package/src/tests/http-users.test.ts +53 -0
- package/src/tests/kapso-client.test.ts +94 -0
- package/src/tests/kapso-inbound.test.ts +257 -0
- package/src/tests/kv-page-proxy.test.ts +1 -0
- package/src/tests/pagination-metrics.test.ts +4 -4
- package/src/tests/prompt-template-session.test.ts +13 -3
- package/src/tests/runner-context-preamble.test.ts +202 -0
- package/src/tests/tool-annotations.test.ts +3 -2
- package/src/tools/cancel-task.ts +13 -5
- package/src/tools/get-task-details.ts +18 -10
- package/src/tools/get-tasks.ts +9 -4
- package/src/tools/register-kapso-number.ts +210 -0
- package/src/tools/send-task.ts +9 -5
- package/src/tools/task-action.ts +20 -10
- package/src/tools/templates.ts +35 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/tools/whatsapp-message.ts +135 -0
- package/templates/skills/agentmail-sending/SKILL.md +169 -0
- package/templates/skills/kapso-whatsapp/SKILL.md +383 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { getAgentModelDisplay } from "../../ui/src/lib/agents-list-model-display";
|
|
3
|
+
|
|
4
|
+
describe("agents list model display", () => {
|
|
5
|
+
test("shows configured and last-used models when they diverge", () => {
|
|
6
|
+
const display = getAgentModelDisplay("claude-opus-4-7", "claude-sonnet-4-6");
|
|
7
|
+
|
|
8
|
+
expect(display).toEqual({
|
|
9
|
+
configured: "claude-opus-4-7",
|
|
10
|
+
lastUsed: "claude-sonnet-4-6",
|
|
11
|
+
primary: "claude-opus-4-7",
|
|
12
|
+
diverged: true,
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("shows one model when configured and last-used match", () => {
|
|
17
|
+
const display = getAgentModelDisplay("claude-sonnet-4-6", "claude-sonnet-4-6");
|
|
18
|
+
|
|
19
|
+
expect(display).toEqual({
|
|
20
|
+
configured: "claude-sonnet-4-6",
|
|
21
|
+
lastUsed: "claude-sonnet-4-6",
|
|
22
|
+
primary: "claude-sonnet-4-6",
|
|
23
|
+
diverged: false,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("shows configured model alone before an agent reports a last-used model", () => {
|
|
28
|
+
const display = getAgentModelDisplay("claude-opus-4-7", null);
|
|
29
|
+
|
|
30
|
+
expect(display.primary).toBe("claude-opus-4-7");
|
|
31
|
+
expect(display.diverged).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { type BasePromptArgs, getBasePrompt } from "../prompts/base-prompt";
|
|
3
3
|
import type { ProviderTraits } from "../providers/types";
|
|
4
4
|
|
|
@@ -9,6 +9,39 @@ const minimalArgs: BasePromptArgs = {
|
|
|
9
9
|
swarmUrl: "swarm.example.com",
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
const originalSlackDisable = process.env.SLACK_DISABLE;
|
|
13
|
+
const originalSlackBotToken = process.env.SLACK_BOT_TOKEN;
|
|
14
|
+
const originalSlackAppToken = process.env.SLACK_APP_TOKEN;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
restoreEnv("SLACK_DISABLE", originalSlackDisable);
|
|
18
|
+
restoreEnv("SLACK_BOT_TOKEN", originalSlackBotToken);
|
|
19
|
+
restoreEnv("SLACK_APP_TOKEN", originalSlackAppToken);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function restoreEnv(
|
|
23
|
+
name: "SLACK_DISABLE" | "SLACK_BOT_TOKEN" | "SLACK_APP_TOKEN",
|
|
24
|
+
value: string | undefined,
|
|
25
|
+
) {
|
|
26
|
+
if (value === undefined) {
|
|
27
|
+
delete process.env[name];
|
|
28
|
+
} else {
|
|
29
|
+
process.env[name] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function enableSlackPromptTools() {
|
|
34
|
+
process.env.SLACK_DISABLE = "false";
|
|
35
|
+
process.env.SLACK_BOT_TOKEN = "xoxb-test-token";
|
|
36
|
+
process.env.SLACK_APP_TOKEN = "xapp-test-token";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function disableSlackPromptTools() {
|
|
40
|
+
process.env.SLACK_DISABLE = "true";
|
|
41
|
+
delete process.env.SLACK_BOT_TOKEN;
|
|
42
|
+
delete process.env.SLACK_APP_TOKEN;
|
|
43
|
+
}
|
|
44
|
+
|
|
12
45
|
// ---------------------------------------------------------------------------
|
|
13
46
|
// Basic fields
|
|
14
47
|
// ---------------------------------------------------------------------------
|
|
@@ -533,3 +566,59 @@ describe("getBasePrompt — local providers unaffected", () => {
|
|
|
533
566
|
expect(result).toContain("/workspace");
|
|
534
567
|
});
|
|
535
568
|
});
|
|
569
|
+
|
|
570
|
+
describe("getBasePrompt — conditional Slack templates", () => {
|
|
571
|
+
test("omits Slack tool templates when Slack is disabled", async () => {
|
|
572
|
+
disableSlackPromptTools();
|
|
573
|
+
|
|
574
|
+
const result = await getBasePrompt({
|
|
575
|
+
...minimalArgs,
|
|
576
|
+
role: "lead",
|
|
577
|
+
slackContext: { channelId: "C123", threadTs: "123.456" },
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
expect(result).not.toMatch(/\bslack-[a-z-]+\b/);
|
|
581
|
+
expect(result).toContain("Task Routing");
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("includes Slack tool template for lead when Slack is enabled", async () => {
|
|
585
|
+
enableSlackPromptTools();
|
|
586
|
+
|
|
587
|
+
const result = await getBasePrompt({
|
|
588
|
+
...minimalArgs,
|
|
589
|
+
role: "lead",
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
expect(result).toContain("#### Slack Tools");
|
|
593
|
+
expect(result).toContain("slack-reply");
|
|
594
|
+
expect(result).toContain("slack-read");
|
|
595
|
+
expect(result).toContain("slack-list-channels");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("includes Slack tool template for worker when Slack is enabled", async () => {
|
|
599
|
+
enableSlackPromptTools();
|
|
600
|
+
|
|
601
|
+
const result = await getBasePrompt({
|
|
602
|
+
...minimalArgs,
|
|
603
|
+
role: "worker",
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
expect(result).toContain("#### Slack Tools");
|
|
607
|
+
expect(result).toContain("slack-reply");
|
|
608
|
+
expect(result).toContain("slack-read");
|
|
609
|
+
expect(result).toContain("slack-list-channels");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("includes worker Slack thread template when Slack is enabled", async () => {
|
|
613
|
+
enableSlackPromptTools();
|
|
614
|
+
|
|
615
|
+
const result = await getBasePrompt({
|
|
616
|
+
...minimalArgs,
|
|
617
|
+
role: "worker",
|
|
618
|
+
slackContext: { channelId: "C123", threadTs: "123.456" },
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
expect(result).toContain("slack-reply");
|
|
622
|
+
expect(result).toContain("C123");
|
|
623
|
+
});
|
|
624
|
+
});
|
|
@@ -439,6 +439,26 @@ describe("GET /api/users/unmapped", () => {
|
|
|
439
439
|
expect(body.unmapped[0]!.count).toBe(5);
|
|
440
440
|
expect(body.unmapped[1]!.externalId).toBe("U_LOW");
|
|
441
441
|
});
|
|
442
|
+
|
|
443
|
+
test("default unmapped list includes Kapso sender identities", async () => {
|
|
444
|
+
const ns = "integration:unmapped:kapso";
|
|
445
|
+
upsertKv({
|
|
446
|
+
namespace: ns,
|
|
447
|
+
key: "34679077777:meta",
|
|
448
|
+
value: { lastSeenAt: "2026-05-20T00:00:00Z", sampleEventType: "kapso.message.received" },
|
|
449
|
+
valueType: "json",
|
|
450
|
+
});
|
|
451
|
+
upsertKv({ namespace: ns, key: "34679077777:count", value: 1, valueType: "integer" });
|
|
452
|
+
|
|
453
|
+
const r = await authedFetch("/api/users/unmapped");
|
|
454
|
+
expect(r.status).toBe(200);
|
|
455
|
+
const body = (await r.json()) as {
|
|
456
|
+
unmapped: Array<{ kind: string; externalId: string }>;
|
|
457
|
+
};
|
|
458
|
+
expect(body.unmapped).toContainEqual(
|
|
459
|
+
expect.objectContaining({ kind: "kapso", externalId: "34679077777" }),
|
|
460
|
+
);
|
|
461
|
+
});
|
|
442
462
|
});
|
|
443
463
|
|
|
444
464
|
describe("POST /api/users/unmapped/:kind/:externalId/resolve", () => {
|
|
@@ -482,6 +502,39 @@ describe("POST /api/users/unmapped/:kind/:externalId/resolve", () => {
|
|
|
482
502
|
expect(user.identities).toContainEqual({ kind: "github", externalId: "ghuser" });
|
|
483
503
|
});
|
|
484
504
|
|
|
505
|
+
test("create-new branch supports phone-only Kapso contacts without email", async () => {
|
|
506
|
+
const ns = "integration:unmapped:kapso";
|
|
507
|
+
upsertKv({
|
|
508
|
+
namespace: ns,
|
|
509
|
+
key: "34679077777:meta",
|
|
510
|
+
value: { lastSeenAt: "x", sampleEventType: "kapso.message.received" },
|
|
511
|
+
valueType: "json",
|
|
512
|
+
});
|
|
513
|
+
upsertKv({ namespace: ns, key: "34679077777:count", value: 1, valueType: "integer" });
|
|
514
|
+
|
|
515
|
+
const r = await authedFetch("/api/users/unmapped/kapso/34679077777/resolve", {
|
|
516
|
+
method: "POST",
|
|
517
|
+
body: JSON.stringify({
|
|
518
|
+
name: "Taras",
|
|
519
|
+
notes: "WhatsApp: +34 679 077 777 (E.164: 34679077777)",
|
|
520
|
+
}),
|
|
521
|
+
});
|
|
522
|
+
expect(r.status).toBe(200);
|
|
523
|
+
const { user } = (await r.json()) as {
|
|
524
|
+
user: {
|
|
525
|
+
id: string;
|
|
526
|
+
name: string;
|
|
527
|
+
email?: string;
|
|
528
|
+
notes?: string;
|
|
529
|
+
identities: Array<{ kind: string; externalId: string }>;
|
|
530
|
+
};
|
|
531
|
+
};
|
|
532
|
+
expect(user.name).toBe("Taras");
|
|
533
|
+
expect(user.email).toBeUndefined();
|
|
534
|
+
expect(user.notes).toContain("E.164: 34679077777");
|
|
535
|
+
expect(user.identities).toContainEqual({ kind: "kapso", externalId: "34679077777" });
|
|
536
|
+
});
|
|
537
|
+
|
|
485
538
|
test("URL-encoded externalId is decoded — kv rows clear, identity stored decoded", async () => {
|
|
486
539
|
// Mimics an AgentMail/Linear @handle entry that contains `@` (or any
|
|
487
540
|
// URL-reserved char). Kv keys are written by the webhook with the literal
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { sendKapsoText } from "../integrations/kapso/client";
|
|
3
|
+
|
|
4
|
+
const originalFetch = globalThis.fetch;
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
globalThis.fetch = originalFetch;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function mockFetch(status: number, body: unknown) {
|
|
11
|
+
globalThis.fetch = (async () =>
|
|
12
|
+
new Response(JSON.stringify(body), {
|
|
13
|
+
status,
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
})) as typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("sendKapsoText", () => {
|
|
19
|
+
test("success → returns outbound wamid", async () => {
|
|
20
|
+
let captured: { url: string; body: unknown } | null = null;
|
|
21
|
+
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
|
22
|
+
captured = { url, body: JSON.parse(init.body as string) };
|
|
23
|
+
return new Response(JSON.stringify({ messages: [{ id: "wamid.OUT123" }] }), { status: 200 });
|
|
24
|
+
}) as typeof fetch;
|
|
25
|
+
|
|
26
|
+
const result = await sendKapsoText({
|
|
27
|
+
apiBaseUrl: "https://api.kapso.ai",
|
|
28
|
+
apiKey: "k",
|
|
29
|
+
phoneNumberId: "1035039933036854",
|
|
30
|
+
to: "34679077777",
|
|
31
|
+
body: "hola",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(result.ok).toBe(true);
|
|
35
|
+
expect(result.messageId).toBe("wamid.OUT123");
|
|
36
|
+
expect(captured!.url).toBe(
|
|
37
|
+
"https://api.kapso.ai/meta/whatsapp/v24.0/1035039933036854/messages",
|
|
38
|
+
);
|
|
39
|
+
expect(captured!.body).toMatchObject({
|
|
40
|
+
messaging_product: "whatsapp",
|
|
41
|
+
to: "34679077777",
|
|
42
|
+
type: "text",
|
|
43
|
+
text: { body: "hola", preview_url: false },
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("quote-reply sets context.message_id", async () => {
|
|
48
|
+
let body: Record<string, unknown> | null = null;
|
|
49
|
+
globalThis.fetch = (async (_url: string, init: RequestInit) => {
|
|
50
|
+
body = JSON.parse(init.body as string);
|
|
51
|
+
return new Response(JSON.stringify({ messages: [{ id: "wamid.R" }] }), { status: 200 });
|
|
52
|
+
}) as typeof fetch;
|
|
53
|
+
|
|
54
|
+
await sendKapsoText({
|
|
55
|
+
apiBaseUrl: "https://api.kapso.ai",
|
|
56
|
+
apiKey: "k",
|
|
57
|
+
phoneNumberId: "p",
|
|
58
|
+
to: "34679077777",
|
|
59
|
+
body: "re",
|
|
60
|
+
contextMessageId: "wamid.IN999",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(body!.context).toEqual({ message_id: "wamid.IN999" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("24h-window error (code 131047) → sessionWindowExpired", async () => {
|
|
67
|
+
mockFetch(400, {
|
|
68
|
+
error: { code: 131047, message: "Message failed: more than 24 hours since last reply" },
|
|
69
|
+
});
|
|
70
|
+
const result = await sendKapsoText({
|
|
71
|
+
apiBaseUrl: "https://api.kapso.ai",
|
|
72
|
+
apiKey: "k",
|
|
73
|
+
phoneNumberId: "p",
|
|
74
|
+
to: "34679077777",
|
|
75
|
+
body: "late",
|
|
76
|
+
});
|
|
77
|
+
expect(result.ok).toBe(false);
|
|
78
|
+
expect(result.sessionWindowExpired).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("generic error → not flagged as session-window", async () => {
|
|
82
|
+
mockFetch(401, { error: { code: 0, message: "Invalid API key" } });
|
|
83
|
+
const result = await sendKapsoText({
|
|
84
|
+
apiBaseUrl: "https://api.kapso.ai",
|
|
85
|
+
apiKey: "bad",
|
|
86
|
+
phoneNumberId: "p",
|
|
87
|
+
to: "34679077777",
|
|
88
|
+
body: "x",
|
|
89
|
+
});
|
|
90
|
+
expect(result.ok).toBe(false);
|
|
91
|
+
expect(result.sessionWindowExpired).toBe(false);
|
|
92
|
+
expect(result.errorMessage).toContain("Invalid API key");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { closeDb, createAgent, createUser, getKv, getTaskById, initDb } from "../be/db";
|
|
5
|
+
import { findUserByExternalId, linkIdentity } from "../be/users";
|
|
6
|
+
import { handleWebhooks } from "../http/webhooks";
|
|
7
|
+
import { putKapsoNumberMapping } from "../integrations/kapso/config";
|
|
8
|
+
import { routeKapsoInbound } from "../integrations/kapso/inbound";
|
|
9
|
+
|
|
10
|
+
const TEST_DB_PATH = "./test-kapso-inbound.sqlite";
|
|
11
|
+
const HMAC_SECRET = "kapso-test-hmac-secret";
|
|
12
|
+
|
|
13
|
+
let agentId: string;
|
|
14
|
+
|
|
15
|
+
function makePayload(opts: {
|
|
16
|
+
phoneNumberId: string;
|
|
17
|
+
messageId?: string;
|
|
18
|
+
direction?: string;
|
|
19
|
+
type?: string;
|
|
20
|
+
text?: string;
|
|
21
|
+
from?: string;
|
|
22
|
+
conversationId?: string;
|
|
23
|
+
}) {
|
|
24
|
+
return {
|
|
25
|
+
message: {
|
|
26
|
+
id: opts.messageId ?? `wamid.${Math.random().toString(36).slice(2)}`,
|
|
27
|
+
from: opts.from ?? "34679077777",
|
|
28
|
+
type: opts.type ?? "text",
|
|
29
|
+
text: { body: opts.text ?? "hola" },
|
|
30
|
+
kapso: { direction: opts.direction ?? "inbound", content: opts.text ?? "hola" },
|
|
31
|
+
},
|
|
32
|
+
conversation: {
|
|
33
|
+
id: opts.conversationId ?? "conv-1",
|
|
34
|
+
phone_number: opts.from ?? "34679077777",
|
|
35
|
+
contact_name: "Taras",
|
|
36
|
+
},
|
|
37
|
+
phone_number_id: opts.phoneNumberId,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sign(secret: string, body: string): string {
|
|
42
|
+
return crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Minimal fake req/res to drive handleWebhooks without a live server. */
|
|
46
|
+
function fakeReqRes(rawBody: string, headers: Record<string, string>) {
|
|
47
|
+
const req = {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers,
|
|
50
|
+
async *[Symbol.asyncIterator]() {
|
|
51
|
+
yield Buffer.from(rawBody);
|
|
52
|
+
},
|
|
53
|
+
} as unknown as IncomingMessage;
|
|
54
|
+
|
|
55
|
+
const captured = { status: 0, body: "" };
|
|
56
|
+
const res = {
|
|
57
|
+
writeHead(status: number) {
|
|
58
|
+
captured.status = status;
|
|
59
|
+
return this;
|
|
60
|
+
},
|
|
61
|
+
end(chunk?: string) {
|
|
62
|
+
if (chunk) captured.body = chunk;
|
|
63
|
+
return this;
|
|
64
|
+
},
|
|
65
|
+
} as unknown as ServerResponse;
|
|
66
|
+
|
|
67
|
+
return { req, res, captured };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const KAPSO_PATH = ["api", "integrations", "kapso", "webhook"];
|
|
71
|
+
|
|
72
|
+
beforeAll(() => {
|
|
73
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
74
|
+
try {
|
|
75
|
+
require("node:fs").unlinkSync(`${TEST_DB_PATH}${suffix}`);
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
initDb(TEST_DB_PATH);
|
|
79
|
+
process.env.KAPSO_WEBHOOK_HMAC_SECRET = HMAC_SECRET;
|
|
80
|
+
const agent = createAgent({ name: "KapsoWorker", isLead: false, status: "idle" });
|
|
81
|
+
agentId = agent.id;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterAll(() => {
|
|
85
|
+
closeDb();
|
|
86
|
+
delete process.env.KAPSO_WEBHOOK_HMAC_SECRET;
|
|
87
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
88
|
+
try {
|
|
89
|
+
require("node:fs").unlinkSync(`${TEST_DB_PATH}${suffix}`);
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("routeKapsoInbound", () => {
|
|
95
|
+
test("mapping hit → dispatches a kapso-inbound task to the mapped agent", () => {
|
|
96
|
+
putKapsoNumberMapping({
|
|
97
|
+
phoneNumberId: "pn-task",
|
|
98
|
+
agentId,
|
|
99
|
+
createdAt: new Date().toISOString(),
|
|
100
|
+
});
|
|
101
|
+
const routing = routeKapsoInbound(makePayload({ phoneNumberId: "pn-task" }));
|
|
102
|
+
expect(routing.kind).toBe("task");
|
|
103
|
+
if (routing.kind !== "task") throw new Error("expected task");
|
|
104
|
+
const task = getTaskById(routing.taskId);
|
|
105
|
+
expect(task).not.toBeNull();
|
|
106
|
+
expect(task!.taskType).toBe("kapso-inbound");
|
|
107
|
+
expect(task!.agentId).toBe(agentId);
|
|
108
|
+
expect(task!.task).toContain("## Source: WhatsApp (Kapso)");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("known Kapso sender → populates requestedByUserId and skips unmapped tracker", () => {
|
|
112
|
+
putKapsoNumberMapping({
|
|
113
|
+
phoneNumberId: "pn-known-sender",
|
|
114
|
+
agentId,
|
|
115
|
+
createdAt: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
const user = createUser({ name: "Known WhatsApp Sender" });
|
|
118
|
+
linkIdentity(user.id, "kapso", "34679077778", { kind: "system", id: "test-fixture" });
|
|
119
|
+
|
|
120
|
+
const routing = routeKapsoInbound(
|
|
121
|
+
makePayload({
|
|
122
|
+
phoneNumberId: "pn-known-sender",
|
|
123
|
+
messageId: "wamid.KNOWN_SENDER",
|
|
124
|
+
from: "+34 679 077 778",
|
|
125
|
+
conversationId: "conv-known-sender",
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(routing.kind).toBe("task");
|
|
130
|
+
if (routing.kind !== "task") throw new Error("expected task");
|
|
131
|
+
const task = getTaskById(routing.taskId);
|
|
132
|
+
expect(task!.requestedByUserId).toBe(user.id);
|
|
133
|
+
expect(getKv("integration:unmapped:kapso", "34679077778:meta")).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("unknown Kapso sender → records unmapped identity and leaves task unowned", () => {
|
|
137
|
+
putKapsoNumberMapping({
|
|
138
|
+
phoneNumberId: "pn-unknown-sender",
|
|
139
|
+
agentId,
|
|
140
|
+
createdAt: new Date().toISOString(),
|
|
141
|
+
});
|
|
142
|
+
expect(findUserByExternalId("kapso", "34679077779")).toBeNull();
|
|
143
|
+
|
|
144
|
+
const routing = routeKapsoInbound(
|
|
145
|
+
makePayload({
|
|
146
|
+
phoneNumberId: "pn-unknown-sender",
|
|
147
|
+
messageId: "wamid.UNKNOWN_SENDER",
|
|
148
|
+
from: "+34 679 077 779",
|
|
149
|
+
conversationId: "conv-unknown-sender",
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(routing.kind).toBe("task");
|
|
154
|
+
if (routing.kind !== "task") throw new Error("expected task");
|
|
155
|
+
const task = getTaskById(routing.taskId);
|
|
156
|
+
expect(task!.requestedByUserId).toBeUndefined();
|
|
157
|
+
|
|
158
|
+
const meta = getKv("integration:unmapped:kapso", "34679077779:meta");
|
|
159
|
+
expect(meta?.valueType).toBe("json");
|
|
160
|
+
expect(meta?.value).toMatchObject({
|
|
161
|
+
sampleEventType: "kapso.message.received",
|
|
162
|
+
});
|
|
163
|
+
expect(String(meta?.value.sampleContext)).toContain("contact=Taras");
|
|
164
|
+
expect(String(meta?.value.sampleContext)).toContain("message=wamid.UNKNOWN_SENDER");
|
|
165
|
+
const count = getKv("integration:unmapped:kapso", "34679077779:count");
|
|
166
|
+
expect(count?.value).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("no mapping → no_mapping (does not break, no task)", () => {
|
|
170
|
+
const routing = routeKapsoInbound(makePayload({ phoneNumberId: "pn-unregistered" }));
|
|
171
|
+
expect(routing.kind).toBe("no_mapping");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("workflow mapping → signals workflow dispatch", () => {
|
|
175
|
+
putKapsoNumberMapping({
|
|
176
|
+
phoneNumberId: "pn-wf",
|
|
177
|
+
workflowId: "11111111-1111-4111-8111-111111111111",
|
|
178
|
+
createdAt: new Date().toISOString(),
|
|
179
|
+
});
|
|
180
|
+
const routing = routeKapsoInbound(makePayload({ phoneNumberId: "pn-wf" }));
|
|
181
|
+
expect(routing.kind).toBe("workflow");
|
|
182
|
+
if (routing.kind !== "workflow") throw new Error("expected workflow");
|
|
183
|
+
expect(routing.workflowId).toBe("11111111-1111-4111-8111-111111111111");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("non-inbound (outbound/status) → skip", () => {
|
|
187
|
+
const routing = routeKapsoInbound(
|
|
188
|
+
makePayload({ phoneNumberId: "pn-task", direction: "outbound" }),
|
|
189
|
+
);
|
|
190
|
+
expect(routing.kind).toBe("skip");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("duplicate delivery of the same message id → second is deduped", () => {
|
|
194
|
+
putKapsoNumberMapping({
|
|
195
|
+
phoneNumberId: "pn-dup",
|
|
196
|
+
agentId,
|
|
197
|
+
createdAt: new Date().toISOString(),
|
|
198
|
+
});
|
|
199
|
+
const messageId = "wamid.DUPLICATE_TEST";
|
|
200
|
+
const first = routeKapsoInbound(makePayload({ phoneNumberId: "pn-dup", messageId }));
|
|
201
|
+
expect(first.kind).toBe("task");
|
|
202
|
+
const second = routeKapsoInbound(makePayload({ phoneNumberId: "pn-dup", messageId }));
|
|
203
|
+
expect(second.kind).toBe("duplicate");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("handleWebhooks — Kapso HMAC gate", () => {
|
|
208
|
+
test("valid HMAC + mapping hit → 200 and task routing", async () => {
|
|
209
|
+
putKapsoNumberMapping({
|
|
210
|
+
phoneNumberId: "pn-http",
|
|
211
|
+
agentId,
|
|
212
|
+
createdAt: new Date().toISOString(),
|
|
213
|
+
});
|
|
214
|
+
const rawBody = JSON.stringify(
|
|
215
|
+
makePayload({ phoneNumberId: "pn-http", messageId: "wamid.HTTP_OK" }),
|
|
216
|
+
);
|
|
217
|
+
const { req, res, captured } = fakeReqRes(rawBody, {
|
|
218
|
+
"x-webhook-signature": sign(HMAC_SECRET, rawBody),
|
|
219
|
+
});
|
|
220
|
+
const handled = await handleWebhooks(req, res, KAPSO_PATH);
|
|
221
|
+
expect(handled).toBe(true);
|
|
222
|
+
expect(captured.status).toBe(200);
|
|
223
|
+
expect(JSON.parse(captured.body)).toMatchObject({ received: true, routing: "task" });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("valid HMAC + no mapping → 200 no_mapping (fallback, does not break)", async () => {
|
|
227
|
+
const rawBody = JSON.stringify(
|
|
228
|
+
makePayload({ phoneNumberId: "pn-http-unmapped", messageId: "wamid.HTTP_NOMAP" }),
|
|
229
|
+
);
|
|
230
|
+
const { req, res, captured } = fakeReqRes(rawBody, {
|
|
231
|
+
"x-webhook-signature": sign(HMAC_SECRET, rawBody),
|
|
232
|
+
});
|
|
233
|
+
await handleWebhooks(req, res, KAPSO_PATH);
|
|
234
|
+
expect(captured.status).toBe(200);
|
|
235
|
+
expect(JSON.parse(captured.body)).toMatchObject({ routing: "no_mapping" });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("invalid HMAC → 401", async () => {
|
|
239
|
+
const rawBody = JSON.stringify(
|
|
240
|
+
makePayload({ phoneNumberId: "pn-http", messageId: "wamid.HTTP_BAD" }),
|
|
241
|
+
);
|
|
242
|
+
const { req, res, captured } = fakeReqRes(rawBody, {
|
|
243
|
+
"x-webhook-signature": sign("wrong-secret", rawBody),
|
|
244
|
+
});
|
|
245
|
+
await handleWebhooks(req, res, KAPSO_PATH);
|
|
246
|
+
expect(captured.status).toBe(401);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("missing signature → 401", async () => {
|
|
250
|
+
const rawBody = JSON.stringify(
|
|
251
|
+
makePayload({ phoneNumberId: "pn-http", messageId: "wamid.HTTP_NOSIG" }),
|
|
252
|
+
);
|
|
253
|
+
const { req, res, captured } = fakeReqRes(rawBody, {});
|
|
254
|
+
await handleWebhooks(req, res, KAPSO_PATH);
|
|
255
|
+
expect(captured.status).toBe(401);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -47,6 +47,8 @@ describe("pagination metrics", () => {
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
test("getTasksCount is filter-aware and independent of limit/offset", () => {
|
|
50
|
+
const totalBefore = getTasksCount();
|
|
51
|
+
|
|
50
52
|
for (let i = 0; i < 7; i++) {
|
|
51
53
|
createTaskExtended(`alpha task ${i}`, { tags: ["alpha"] });
|
|
52
54
|
}
|
|
@@ -66,7 +68,7 @@ describe("pagination metrics", () => {
|
|
|
66
68
|
expect(getTasksCount({ tags: ["alpha"], limit: 2, offset: 0 })).toBe(7);
|
|
67
69
|
|
|
68
70
|
// The unfiltered count covers every task created above.
|
|
69
|
-
expect(getTasksCount()).toBe(10);
|
|
71
|
+
expect(getTasksCount() - totalBefore).toBe(10);
|
|
70
72
|
});
|
|
71
73
|
|
|
72
74
|
test("getTasksCount filter-aware on search", () => {
|
|
@@ -123,9 +125,7 @@ describe("pagination metrics", () => {
|
|
|
123
125
|
expect(countSessions({ source: ["slack"] }) - slackBefore).toBe(2);
|
|
124
126
|
expect(countSessions({ source: ["mcp", "slack"] }) - bothBefore).toBe(7);
|
|
125
127
|
// q filter narrows on top of source.
|
|
126
|
-
expect(countSessions({ source: ["slack"], q: "slack session" })).toBe(
|
|
127
|
-
countSessions({ source: ["slack"] }),
|
|
128
|
-
);
|
|
128
|
+
expect(countSessions({ source: ["slack"], q: "slack session" }) - slackBefore).toBe(2);
|
|
129
129
|
expect(countSessions({ q: "no-such-session-marker-zzz" })).toBe(0);
|
|
130
130
|
});
|
|
131
131
|
|
|
@@ -54,11 +54,12 @@ describe("Session templates — registration", () => {
|
|
|
54
54
|
await ensureTemplatesRegistered();
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
test("all
|
|
57
|
+
test("all 14 system templates are registered", () => {
|
|
58
58
|
const systemTemplates = [
|
|
59
59
|
"system.agent.role",
|
|
60
60
|
"system.agent.register",
|
|
61
61
|
"system.agent.lead",
|
|
62
|
+
"system.agent.slack",
|
|
62
63
|
"system.agent.worker",
|
|
63
64
|
"system.agent.worker.slack",
|
|
64
65
|
"system.agent.filesystem",
|
|
@@ -89,10 +90,10 @@ describe("Session templates — registration", () => {
|
|
|
89
90
|
}
|
|
90
91
|
});
|
|
91
92
|
|
|
92
|
-
test("total of
|
|
93
|
+
test("total of 19 session/system templates registered", () => {
|
|
93
94
|
const all = getAllTemplateDefinitions();
|
|
94
95
|
const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
|
|
95
|
-
expect(sessionSystem.length).toBe(
|
|
96
|
+
expect(sessionSystem.length).toBe(19);
|
|
96
97
|
});
|
|
97
98
|
});
|
|
98
99
|
|
|
@@ -150,6 +151,15 @@ describe("Session templates — individual resolution", () => {
|
|
|
150
151
|
const result = resolveTemplate("system.agent.lead", {});
|
|
151
152
|
expect(result.text).toContain("CRITICAL: You are a coordinator");
|
|
152
153
|
expect(result.text).toContain("coordinator");
|
|
154
|
+
expect(result.text).not.toContain("slack-reply");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("system.agent.slack contains Slack tool guidance", () => {
|
|
158
|
+
const result = resolveTemplate("system.agent.slack", {});
|
|
159
|
+
expect(result.text).toContain("Slack Tools");
|
|
160
|
+
expect(result.text).toContain("slack-reply");
|
|
161
|
+
expect(result.text).toContain("slack-read");
|
|
162
|
+
expect(result.text).toContain("slack-list-channels");
|
|
153
163
|
});
|
|
154
164
|
|
|
155
165
|
test("system.agent.worker contains worker tools", () => {
|