@desplega.ai/agent-swarm 1.84.0 → 1.85.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/README.md +48 -8
- package/openapi.json +5 -3
- package/package.json +1 -1
- package/src/be/db-queries/oauth.ts +33 -0
- package/src/be/db.ts +7 -1
- package/src/be/migrations/076_kapso_sender_user_backfill.sql +43 -0
- package/src/be/migrations/077_oauth_refresh_locks.sql +8 -0
- package/src/commands/context-preamble.ts +178 -0
- package/src/commands/runner.ts +87 -7
- package/src/http/index.ts +11 -3
- package/src/http/tasks.ts +17 -0
- package/src/http/users.ts +11 -3
- package/src/http/utils.ts +17 -0
- package/src/integrations/kapso/inbound.ts +36 -0
- package/src/oauth/ensure-token.ts +97 -11
- package/src/prompts/base-prompt.ts +15 -2
- package/src/prompts/session-templates.ts +26 -12
- package/src/providers/pi-mono-adapter.ts +44 -25
- package/src/server.ts +2 -0
- package/src/tasks/worker-follow-up.ts +82 -0
- package/src/tests/agentmail-sending-skill.test.ts +75 -0
- package/src/tests/agents-list-model-display.test.ts +45 -0
- package/src/tests/base-prompt.test.ts +90 -1
- package/src/tests/db-queries-oauth.test.ts +27 -0
- package/src/tests/ensure-token.test.ts +71 -0
- package/src/tests/http-log-scrubbing.test.ts +24 -0
- package/src/tests/http-users.test.ts +53 -0
- package/src/tests/kapso-inbound.test.ts +60 -1
- package/src/tests/kv-page-proxy.test.ts +1 -0
- package/src/tests/list-endpoint-slimming.test.ts +22 -1
- package/src/tests/oauth-access-token-tool.test.ts +138 -0
- package/src/tests/pagination-metrics.test.ts +4 -4
- package/src/tests/pi-mono-adapter.test.ts +37 -1
- package/src/tests/prompt-template-session.test.ts +13 -3
- package/src/tests/runner-context-preamble.test.ts +202 -0
- package/src/tests/runner-fallback-output.test.ts +118 -39
- package/src/tests/task-completion-idempotency.test.ts +89 -0
- 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/oauth-access-token.ts +118 -0
- package/src/tools/send-task.ts +9 -5
- package/src/tools/store-progress.ts +12 -77
- package/src/tools/task-action.ts +20 -10
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +5 -0
- package/src/utils/secret-scrubber.ts +23 -0
- package/templates/skills/agentmail-sending/SKILL.md +148 -28
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
getAgentModelDisplay,
|
|
4
|
+
getAgentModelPresentation,
|
|
5
|
+
} from "../../ui/src/lib/agents-list-model-display";
|
|
6
|
+
|
|
7
|
+
describe("agents list model display", () => {
|
|
8
|
+
test("shows configured and last-used models when they diverge", () => {
|
|
9
|
+
const display = getAgentModelDisplay("claude-opus-4-7", "claude-sonnet-4-6");
|
|
10
|
+
|
|
11
|
+
expect(display).toEqual({
|
|
12
|
+
configured: "claude-opus-4-7",
|
|
13
|
+
lastUsed: "claude-sonnet-4-6",
|
|
14
|
+
primary: "claude-opus-4-7",
|
|
15
|
+
diverged: true,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("shows one model when configured and last-used match", () => {
|
|
20
|
+
const display = getAgentModelDisplay("claude-sonnet-4-6", "claude-sonnet-4-6");
|
|
21
|
+
|
|
22
|
+
expect(display).toEqual({
|
|
23
|
+
configured: "claude-sonnet-4-6",
|
|
24
|
+
lastUsed: "claude-sonnet-4-6",
|
|
25
|
+
primary: "claude-sonnet-4-6",
|
|
26
|
+
diverged: false,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("shows configured model alone before an agent reports a last-used model", () => {
|
|
31
|
+
const display = getAgentModelDisplay("claude-opus-4-7", null);
|
|
32
|
+
|
|
33
|
+
expect(display.primary).toBe("claude-opus-4-7");
|
|
34
|
+
expect(display.diverged).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("presents known provider-prefixed model ids as readable labels", () => {
|
|
38
|
+
expect(getAgentModelPresentation("openrouter/deepseek/deepseek-v4-flash")).toEqual({
|
|
39
|
+
raw: "openrouter/deepseek/deepseek-v4-flash",
|
|
40
|
+
label: "DeepSeek V4 Flash",
|
|
41
|
+
provider: "OpenRouter",
|
|
42
|
+
providerId: "openrouter",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -2,10 +2,12 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import { closeDb, initDb } from "../be/db";
|
|
4
4
|
import {
|
|
5
|
+
acquireOAuthRefreshLock,
|
|
5
6
|
deleteOAuthTokens,
|
|
6
7
|
getOAuthApp,
|
|
7
8
|
getOAuthTokens,
|
|
8
9
|
isTokenExpiringSoon,
|
|
10
|
+
releaseOAuthRefreshLock,
|
|
9
11
|
storeOAuthTokens,
|
|
10
12
|
updateOAuthTokensAfterRefresh,
|
|
11
13
|
upsertOAuthApp,
|
|
@@ -238,3 +240,28 @@ describe("isTokenExpiringSoon", () => {
|
|
|
238
240
|
expect(isTokenExpiringSoon("expiry-test", 180000)).toBe(true);
|
|
239
241
|
});
|
|
240
242
|
});
|
|
243
|
+
|
|
244
|
+
describe("OAuth refresh locks", () => {
|
|
245
|
+
test("allows only one owner until the lock is released", () => {
|
|
246
|
+
const owner = acquireOAuthRefreshLock("lock-test", 60_000);
|
|
247
|
+
expect(typeof owner).toBe("string");
|
|
248
|
+
|
|
249
|
+
expect(acquireOAuthRefreshLock("lock-test", 60_000)).toBeNull();
|
|
250
|
+
|
|
251
|
+
releaseOAuthRefreshLock("lock-test", owner!);
|
|
252
|
+
const nextOwner = acquireOAuthRefreshLock("lock-test", 60_000);
|
|
253
|
+
expect(typeof nextOwner).toBe("string");
|
|
254
|
+
releaseOAuthRefreshLock("lock-test", nextOwner!);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("allows a new owner after the lock expires", () => {
|
|
258
|
+
const expiredOwner = acquireOAuthRefreshLock("expired-lock-test", -1_000);
|
|
259
|
+
expect(typeof expiredOwner).toBe("string");
|
|
260
|
+
|
|
261
|
+
const nextOwner = acquireOAuthRefreshLock("expired-lock-test", 60_000);
|
|
262
|
+
expect(typeof nextOwner).toBe("string");
|
|
263
|
+
expect(nextOwner).not.toBe(expiredOwner);
|
|
264
|
+
|
|
265
|
+
releaseOAuthRefreshLock("expired-lock-test", nextOwner!);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -300,6 +300,77 @@ describe("ensureTokenOrThrow", () => {
|
|
|
300
300
|
expect(tokens?.refreshToken).toBe("new-jira-refresh");
|
|
301
301
|
});
|
|
302
302
|
|
|
303
|
+
test("serializes concurrent Jira refresh callers before the token endpoint", async () => {
|
|
304
|
+
storeOAuthTokens("jira", {
|
|
305
|
+
accessToken: "old-jira-access",
|
|
306
|
+
refreshToken: "old-jira-refresh",
|
|
307
|
+
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const fetchSpy = mock(() =>
|
|
311
|
+
Promise.resolve(
|
|
312
|
+
new Response(
|
|
313
|
+
JSON.stringify({
|
|
314
|
+
access_token: "new-jira-access",
|
|
315
|
+
token_type: "Bearer",
|
|
316
|
+
expires_in: 3600,
|
|
317
|
+
refresh_token: "new-jira-refresh",
|
|
318
|
+
}),
|
|
319
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
320
|
+
),
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
globalThis.fetch = fetchSpy;
|
|
324
|
+
|
|
325
|
+
await Promise.all([
|
|
326
|
+
ensureTokenOrThrow("jira"),
|
|
327
|
+
ensureTokenOrThrow("jira"),
|
|
328
|
+
ensureTokenOrThrow("jira"),
|
|
329
|
+
]);
|
|
330
|
+
|
|
331
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
332
|
+
const [_url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
333
|
+
expect(init.body).toContain("refresh_token=old-jira-refresh");
|
|
334
|
+
|
|
335
|
+
const tokens = getOAuthTokens("jira");
|
|
336
|
+
expect(tokens?.accessToken).toBe("new-jira-access");
|
|
337
|
+
expect(tokens?.refreshToken).toBe("new-jira-refresh");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("does not rotate again when a concurrent caller already changed the token row", async () => {
|
|
341
|
+
storeOAuthTokens("jira", {
|
|
342
|
+
accessToken: "old-jira-access",
|
|
343
|
+
refreshToken: "old-jira-refresh",
|
|
344
|
+
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const fetchSpy = mock(() =>
|
|
348
|
+
Promise.resolve(
|
|
349
|
+
new Response(
|
|
350
|
+
JSON.stringify({
|
|
351
|
+
access_token: "new-jira-access",
|
|
352
|
+
token_type: "Bearer",
|
|
353
|
+
expires_in: 3600,
|
|
354
|
+
refresh_token: "new-jira-refresh",
|
|
355
|
+
}),
|
|
356
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
357
|
+
),
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
globalThis.fetch = fetchSpy;
|
|
361
|
+
|
|
362
|
+
await Promise.all([
|
|
363
|
+
ensureTokenOrThrow("jira", 65 * 60 * 1000),
|
|
364
|
+
ensureTokenOrThrow("jira", 65 * 60 * 1000),
|
|
365
|
+
ensureTokenOrThrow("jira", 65 * 60 * 1000),
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
369
|
+
const tokens = getOAuthTokens("jira");
|
|
370
|
+
expect(tokens?.accessToken).toBe("new-jira-access");
|
|
371
|
+
expect(tokens?.refreshToken).toBe("new-jira-refresh");
|
|
372
|
+
});
|
|
373
|
+
|
|
303
374
|
test("rejects a Jira refresh response that omits the rotated refresh token", async () => {
|
|
304
375
|
storeOAuthTokens("jira", {
|
|
305
376
|
accessToken: "old-jira-access",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { safeRequestUrlForLog } from "../http/utils";
|
|
3
|
+
|
|
4
|
+
describe("safeRequestUrlForLog", () => {
|
|
5
|
+
test("redacts OAuth callback query values", () => {
|
|
6
|
+
expect(
|
|
7
|
+
safeRequestUrlForLog(
|
|
8
|
+
"/api/trackers/jira/callback?state=opaque-state-value&code=oauth-code-value",
|
|
9
|
+
),
|
|
10
|
+
).toBe("/api/trackers/jira/callback?state=[REDACTED]&code=[REDACTED]");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("preserves paths without query strings", () => {
|
|
14
|
+
expect(safeRequestUrlForLog("/api/trackers/jira/authorize")).toBe(
|
|
15
|
+
"/api/trackers/jira/authorize",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("redacts every query parameter value in order", () => {
|
|
20
|
+
expect(safeRequestUrlForLog("/mcp?session=abc&session=def&token=secret")).toBe(
|
|
21
|
+
"/mcp?session=[REDACTED]&session=[REDACTED]&token=[REDACTED]",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -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
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
-
import { closeDb, createAgent, getTaskById, initDb } from "../be/db";
|
|
4
|
+
import { closeDb, createAgent, createUser, getKv, getTaskById, initDb } from "../be/db";
|
|
5
|
+
import { findUserByExternalId, linkIdentity } from "../be/users";
|
|
5
6
|
import { handleWebhooks } from "../http/webhooks";
|
|
6
7
|
import { putKapsoNumberMapping } from "../integrations/kapso/config";
|
|
7
8
|
import { routeKapsoInbound } from "../integrations/kapso/inbound";
|
|
@@ -107,6 +108,64 @@ describe("routeKapsoInbound", () => {
|
|
|
107
108
|
expect(task!.task).toContain("## Source: WhatsApp (Kapso)");
|
|
108
109
|
});
|
|
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
|
+
|
|
110
169
|
test("no mapping → no_mapping (does not break, no task)", () => {
|
|
111
170
|
const routing = routeKapsoInbound(makePayload({ phoneNumberId: "pn-unregistered" }));
|
|
112
171
|
expect(routing.kind).toBe("no_mapping");
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
createAgent,
|
|
6
6
|
createPage,
|
|
7
7
|
createScheduledTask,
|
|
8
|
+
createSessionCost,
|
|
8
9
|
createTaskExtended,
|
|
9
10
|
createWorkflow,
|
|
10
11
|
getAllAgents,
|
|
@@ -145,7 +146,25 @@ describe("list-endpoint slimming", () => {
|
|
|
145
146
|
|
|
146
147
|
test("getAllTasks — slim truncates task text and drops heavy blobs", () => {
|
|
147
148
|
const longText = "Z".repeat(2000);
|
|
148
|
-
createTaskExtended(longText, { agentId: "slim-agent-1" });
|
|
149
|
+
const task = createTaskExtended(longText, { agentId: "slim-agent-1" });
|
|
150
|
+
createSessionCost({
|
|
151
|
+
sessionId: "slim-cost-session-1",
|
|
152
|
+
taskId: task.id,
|
|
153
|
+
agentId: "slim-agent-1",
|
|
154
|
+
totalCostUsd: 0.0123,
|
|
155
|
+
durationMs: 1000,
|
|
156
|
+
numTurns: 1,
|
|
157
|
+
model: "test-model",
|
|
158
|
+
});
|
|
159
|
+
createSessionCost({
|
|
160
|
+
sessionId: "slim-cost-session-2",
|
|
161
|
+
taskId: task.id,
|
|
162
|
+
agentId: "slim-agent-1",
|
|
163
|
+
totalCostUsd: 0.0045,
|
|
164
|
+
durationMs: 1000,
|
|
165
|
+
numTurns: 1,
|
|
166
|
+
model: "test-model",
|
|
167
|
+
});
|
|
149
168
|
|
|
150
169
|
const slim = getAllTasks({}, { slim: true });
|
|
151
170
|
const slimTask = slim.find((t) => t.task.startsWith("Z"));
|
|
@@ -155,10 +174,12 @@ describe("list-endpoint slimming", () => {
|
|
|
155
174
|
expect("output" in slimTask!).toBe(false);
|
|
156
175
|
expect("failureReason" in slimTask!).toBe(false);
|
|
157
176
|
expect("providerMeta" in slimTask!).toBe(false);
|
|
177
|
+
expect(slimTask?.totalCostUsd).toBeCloseTo(0.0168, 6);
|
|
158
178
|
|
|
159
179
|
const full = getAllTasks({}).find((t) => t.task === longText);
|
|
160
180
|
expect(full).toBeDefined();
|
|
161
181
|
expect(full?.task).toBe(longText);
|
|
182
|
+
expect(full?.totalCostUsd).toBeCloseTo(0.0168, 6);
|
|
162
183
|
});
|
|
163
184
|
|
|
164
185
|
test("listRecentSessions — slim root is a truncated task summary", () => {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, initDb } from "../be/db";
|
|
4
|
+
import {
|
|
5
|
+
deleteOAuthTokens,
|
|
6
|
+
getOAuthTokens,
|
|
7
|
+
storeOAuthTokens,
|
|
8
|
+
upsertOAuthApp,
|
|
9
|
+
} from "../be/db-queries/oauth";
|
|
10
|
+
import { resolveOAuthAccessToken } from "../tools/oauth-access-token";
|
|
11
|
+
import {
|
|
12
|
+
clearVolatileSecretsForTesting,
|
|
13
|
+
refreshSecretScrubberCache,
|
|
14
|
+
scrubSecrets,
|
|
15
|
+
} from "../utils/secret-scrubber";
|
|
16
|
+
|
|
17
|
+
const TEST_DB_PATH = "./test-oauth-access-token-tool.sqlite";
|
|
18
|
+
const originalFetch = globalThis.fetch;
|
|
19
|
+
|
|
20
|
+
const testApp = {
|
|
21
|
+
clientId: "client-id",
|
|
22
|
+
clientSecret: "client-secret",
|
|
23
|
+
authorizeUrl: "https://example.com/oauth/authorize",
|
|
24
|
+
tokenUrl: "https://example.com/oauth/token",
|
|
25
|
+
redirectUri: "http://localhost:3013/callback",
|
|
26
|
+
scopes: "read,write",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
initDb(TEST_DB_PATH);
|
|
31
|
+
upsertOAuthApp("linear", testApp);
|
|
32
|
+
upsertOAuthApp("jira", {
|
|
33
|
+
...testApp,
|
|
34
|
+
tokenUrl: "https://example.com/jira/oauth/token",
|
|
35
|
+
});
|
|
36
|
+
upsertOAuthApp("custom-provider", {
|
|
37
|
+
...testApp,
|
|
38
|
+
tokenUrl: "https://example.com/custom/oauth/token",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
deleteOAuthTokens("linear");
|
|
44
|
+
deleteOAuthTokens("jira");
|
|
45
|
+
deleteOAuthTokens("custom-provider");
|
|
46
|
+
globalThis.fetch = originalFetch;
|
|
47
|
+
clearVolatileSecretsForTesting();
|
|
48
|
+
refreshSecretScrubberCache();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
globalThis.fetch = originalFetch;
|
|
53
|
+
clearVolatileSecretsForTesting();
|
|
54
|
+
refreshSecretScrubberCache();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
globalThis.fetch = originalFetch;
|
|
59
|
+
closeDb();
|
|
60
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
61
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
62
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("resolveOAuthAccessToken", () => {
|
|
66
|
+
test("returns a fresh access token and registers it for scrubber redaction", async () => {
|
|
67
|
+
const accessToken = "linear-access-token-plain-value-1234567890";
|
|
68
|
+
storeOAuthTokens("linear", {
|
|
69
|
+
accessToken,
|
|
70
|
+
refreshToken: "linear-refresh-token",
|
|
71
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = await resolveOAuthAccessToken("linear");
|
|
75
|
+
|
|
76
|
+
expect(result).toEqual({
|
|
77
|
+
provider: "linear",
|
|
78
|
+
accessToken,
|
|
79
|
+
expiresAt: result.expiresAt,
|
|
80
|
+
tokenType: "Bearer",
|
|
81
|
+
});
|
|
82
|
+
expect(scrubSecrets(`Authorization: Bearer ${accessToken}`)).toBe(
|
|
83
|
+
"Authorization: Bearer [REDACTED:LINEAR_OAUTH_ACCESS_TOKEN]",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("supports any configured OAuth provider slug", async () => {
|
|
88
|
+
storeOAuthTokens("custom-provider", {
|
|
89
|
+
accessToken: "custom-provider-access-token-plain-value",
|
|
90
|
+
refreshToken: "custom-provider-refresh-token",
|
|
91
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await resolveOAuthAccessToken("custom-provider");
|
|
95
|
+
|
|
96
|
+
expect(result.provider).toBe("custom-provider");
|
|
97
|
+
expect(result.accessToken).toBe("custom-provider-access-token-plain-value");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("refreshes Jira before returning a near-expiry token", async () => {
|
|
101
|
+
storeOAuthTokens("jira", {
|
|
102
|
+
accessToken: "old-jira-access-token",
|
|
103
|
+
refreshToken: "old-jira-refresh-token",
|
|
104
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const fetchSpy = mock(() =>
|
|
108
|
+
Promise.resolve(
|
|
109
|
+
new Response(
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
access_token: "new-jira-access-token-plain-value",
|
|
112
|
+
token_type: "Bearer",
|
|
113
|
+
expires_in: 3600,
|
|
114
|
+
refresh_token: "new-jira-refresh-token",
|
|
115
|
+
}),
|
|
116
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
globalThis.fetch = fetchSpy;
|
|
121
|
+
|
|
122
|
+
const result = await resolveOAuthAccessToken("jira");
|
|
123
|
+
|
|
124
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
125
|
+
expect(result.accessToken).toBe("new-jira-access-token-plain-value");
|
|
126
|
+
expect(getOAuthTokens("jira")?.refreshToken).toBe("new-jira-refresh-token");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("rejects a near-expiry token when no refresh token is available", async () => {
|
|
130
|
+
storeOAuthTokens("jira", {
|
|
131
|
+
accessToken: "stale-jira-access-token",
|
|
132
|
+
refreshToken: null,
|
|
133
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await expect(resolveOAuthAccessToken("jira")).rejects.toThrow(/could not be refreshed/);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -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
|
|