@desplega.ai/agent-swarm 1.84.0 → 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 +5 -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/integrations/kapso/inbound.ts +36 -0
- package/src/prompts/base-prompt.ts +15 -2
- package/src/prompts/session-templates.ts +26 -12
- 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-inbound.test.ts +60 -1
- 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/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/send-task.ts +9 -5
- package/src/tools/task-action.ts +20 -10
- package/templates/skills/agentmail-sending/SKILL.md +148 -28
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const skillPath = `${import.meta.dir}/../../templates/skills/agentmail-sending/SKILL.md`;
|
|
4
|
+
const skill = await Bun.file(skillPath).text();
|
|
5
|
+
const curlInboxVariable = "$" + "{INBOX}";
|
|
6
|
+
const scriptApiKeyVariable = "$" + "{apiKey}";
|
|
7
|
+
|
|
8
|
+
function requireMatch(pattern: RegExp, label: string): RegExpMatchArray {
|
|
9
|
+
const match = skill.match(pattern);
|
|
10
|
+
if (!match) {
|
|
11
|
+
throw new Error(`Missing ${label}`);
|
|
12
|
+
}
|
|
13
|
+
return match;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("agentmail-sending skill template", () => {
|
|
17
|
+
test("pins the canonical base URL and rejects the hallucinated host", () => {
|
|
18
|
+
expect(skill).toContain("```text\nhttps://api.agentmail.to/v0/\n```");
|
|
19
|
+
expect(skill).toContain("DO NOT use `api.agentmail.ai`");
|
|
20
|
+
expect(skill).not.toContain("https://api.agentmail.ai");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("pins send-message field names and text-only guidance", () => {
|
|
24
|
+
expect(skill).toContain("```text\nto\nbcc\nsubject\ntext\n```");
|
|
25
|
+
expect(skill).toContain("Use `text`, NOT `text_body`, `body`, or `content`.");
|
|
26
|
+
expect(skill).toContain("Do NOT pass `html`.");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("curl example uses the canonical endpoint, bearer auth, and exact JSON fields", () => {
|
|
30
|
+
expect(skill).toContain("https://api.agentmail.to/v0/inboxes/{inbox}/messages/send");
|
|
31
|
+
expect(skill).toContain(
|
|
32
|
+
[
|
|
33
|
+
'curl -sS -X POST "https://api.agentmail.to/v0/inboxes/',
|
|
34
|
+
curlInboxVariable,
|
|
35
|
+
'/messages/send"',
|
|
36
|
+
].join(""),
|
|
37
|
+
);
|
|
38
|
+
expect(skill).toContain('-H "Authorization: Bearer $AGENTMAIL_API_KEY"');
|
|
39
|
+
|
|
40
|
+
const jsonBlock = requireMatch(
|
|
41
|
+
/--data-binary @- <<'JSON'\n([\s\S]*?)\nJSON/,
|
|
42
|
+
"curl JSON body",
|
|
43
|
+
)[1];
|
|
44
|
+
const payload = JSON.parse(jsonBlock);
|
|
45
|
+
|
|
46
|
+
expect(Object.keys(payload)).toEqual(["to", "bcc", "subject", "text"]);
|
|
47
|
+
expect(payload).not.toHaveProperty("text_body");
|
|
48
|
+
expect(payload).not.toHaveProperty("body");
|
|
49
|
+
expect(payload).not.toHaveProperty("content");
|
|
50
|
+
expect(payload).not.toHaveProperty("html");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("script_upsert example uses fetch and resolves AGENTMAIL_API_KEY from swarm config", () => {
|
|
54
|
+
const scriptBlock = requireMatch(/```ts\n([\s\S]*?)\n```/, "script_upsert example")[1];
|
|
55
|
+
|
|
56
|
+
expect(scriptBlock).toContain("await script_upsert({");
|
|
57
|
+
expect(scriptBlock).toContain("ctx.swarm.config.get('AGENTMAIL_API_KEY')");
|
|
58
|
+
expect(scriptBlock).toContain("ctx.stdlib.fetch(");
|
|
59
|
+
expect(scriptBlock).toContain("https://api.agentmail.to/v0/inboxes/");
|
|
60
|
+
expect(scriptBlock).toContain("messages/send");
|
|
61
|
+
expect(scriptBlock).toContain(
|
|
62
|
+
["Authorization: \\`Bearer \\", scriptApiKeyVariable, "\\`"].join(""),
|
|
63
|
+
);
|
|
64
|
+
expect(scriptBlock).toContain("text: args.text");
|
|
65
|
+
expect(scriptBlock).not.toContain("text_body");
|
|
66
|
+
expect(scriptBlock).not.toContain("html:");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("common error table covers known AgentMail mistakes", () => {
|
|
70
|
+
expect(skill).toContain("404 on `/v0/inboxes/.../send`");
|
|
71
|
+
expect(skill).toContain('422 `{"detail":"text Field required"}`');
|
|
72
|
+
expect(skill).toContain("401");
|
|
73
|
+
expect(skill).toContain("HTML rendering bug");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -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
|
|
@@ -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");
|
|
@@ -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", () => {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import {
|
|
4
|
+
buildContextPreamble,
|
|
5
|
+
CONTEXT_PREAMBLE_MAX_CHARS,
|
|
6
|
+
CONTEXT_PREAMBLE_MAX_TOKENS,
|
|
7
|
+
fetchTaskContextForPreamble,
|
|
8
|
+
type TaskContextForPreamble,
|
|
9
|
+
} from "../commands/context-preamble";
|
|
10
|
+
|
|
11
|
+
const TEST_PORT = 19091;
|
|
12
|
+
const API_URL = `http://localhost:${TEST_PORT}`;
|
|
13
|
+
const API_KEY = "test-key";
|
|
14
|
+
|
|
15
|
+
// In-memory task store for the mock server
|
|
16
|
+
const mockTasks: Record<string, TaskContextForPreamble> = {};
|
|
17
|
+
|
|
18
|
+
let server: Server;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
server = createServer((req, res) => {
|
|
22
|
+
const url = req.url ?? "";
|
|
23
|
+
const match = url.match(/^\/api\/tasks\/([^/?]+)/);
|
|
24
|
+
if (match) {
|
|
25
|
+
const id = match[1];
|
|
26
|
+
const task = mockTasks[id];
|
|
27
|
+
if (task) {
|
|
28
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
29
|
+
res.end(JSON.stringify(task));
|
|
30
|
+
} else {
|
|
31
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
32
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
res.writeHead(404);
|
|
37
|
+
res.end();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, resolve));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
server.close();
|
|
45
|
+
// Clear mocks
|
|
46
|
+
for (const k of Object.keys(mockTasks)) delete mockTasks[k];
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function seedTask(task: TaskContextForPreamble): void {
|
|
50
|
+
mockTasks[task.id] = task;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("fetchTaskContextForPreamble", () => {
|
|
54
|
+
test("returns null on 404", async () => {
|
|
55
|
+
const result = await fetchTaskContextForPreamble(API_URL, API_KEY, "missing-id");
|
|
56
|
+
expect(result).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("fetches task context fields", async () => {
|
|
60
|
+
seedTask({
|
|
61
|
+
id: "task-a",
|
|
62
|
+
task: "Build the widget",
|
|
63
|
+
output: "Widget built successfully",
|
|
64
|
+
status: "completed",
|
|
65
|
+
attachments: [{ kind: "url", name: "Report", url: "https://example.com/report" }],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await fetchTaskContextForPreamble(API_URL, API_KEY, "task-a");
|
|
69
|
+
expect(result).not.toBeNull();
|
|
70
|
+
expect(result?.id).toBe("task-a");
|
|
71
|
+
expect(result?.task).toBe("Build the widget");
|
|
72
|
+
expect(result?.output).toBe("Widget built successfully");
|
|
73
|
+
expect(result?.attachments).toHaveLength(1);
|
|
74
|
+
expect(result?.attachments?.[0].name).toBe("Report");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("buildContextPreamble", () => {
|
|
79
|
+
test("returns null when parent task not found", async () => {
|
|
80
|
+
const result = await buildContextPreamble(API_URL, API_KEY, "nonexistent-parent");
|
|
81
|
+
expect(result).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("includes parent task subject and output in preamble", async () => {
|
|
85
|
+
seedTask({
|
|
86
|
+
id: "parent-1",
|
|
87
|
+
task: "Fix the auth bug in login flow",
|
|
88
|
+
output: "Fixed by patching jwt validation in auth.ts:42",
|
|
89
|
+
status: "completed",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-1");
|
|
93
|
+
expect(preamble).not.toBeNull();
|
|
94
|
+
expect(preamble).toContain("parent-1");
|
|
95
|
+
expect(preamble).toContain("Fix the auth bug in login flow");
|
|
96
|
+
expect(preamble).toContain("Fixed by patching jwt validation");
|
|
97
|
+
expect(preamble).toContain("get-task-details");
|
|
98
|
+
expect(preamble).toContain("Prior Conversation Context");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("includes attachment pointers in preamble", async () => {
|
|
102
|
+
seedTask({
|
|
103
|
+
id: "parent-2",
|
|
104
|
+
task: "Generate a report",
|
|
105
|
+
output: "Report generated",
|
|
106
|
+
status: "completed",
|
|
107
|
+
attachments: [
|
|
108
|
+
{ kind: "url", name: "Final Report", url: "https://example.com/report.pdf" },
|
|
109
|
+
{
|
|
110
|
+
kind: "agent-fs",
|
|
111
|
+
name: "Raw Data",
|
|
112
|
+
path: "thoughts/agent/research/data.md",
|
|
113
|
+
orgId: "org-123",
|
|
114
|
+
driveId: "drv-456",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-2");
|
|
120
|
+
expect(preamble).toContain("Final Report");
|
|
121
|
+
expect(preamble).toContain("https://example.com/report.pdf");
|
|
122
|
+
expect(preamble).toContain("Raw Data");
|
|
123
|
+
expect(preamble).toContain("live.agent-fs.dev");
|
|
124
|
+
expect(preamble).toContain("org-123");
|
|
125
|
+
expect(preamble).toContain("drv-456");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("shows 'no output recorded' when task has no output or progress", async () => {
|
|
129
|
+
seedTask({
|
|
130
|
+
id: "parent-no-output",
|
|
131
|
+
task: "A task with no output yet",
|
|
132
|
+
status: "in_progress",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-no-output");
|
|
136
|
+
expect(preamble).toContain("no output recorded");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("walks ancestor chain and includes older ancestors as pointers", async () => {
|
|
140
|
+
seedTask({
|
|
141
|
+
id: "grandparent-1",
|
|
142
|
+
task: "Initial research task",
|
|
143
|
+
output: "Research complete",
|
|
144
|
+
status: "completed",
|
|
145
|
+
});
|
|
146
|
+
seedTask({
|
|
147
|
+
id: "child-of-grandparent",
|
|
148
|
+
task: "Second task referencing research",
|
|
149
|
+
output: "Second task done",
|
|
150
|
+
status: "completed",
|
|
151
|
+
parentTaskId: "grandparent-1",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "child-of-grandparent");
|
|
155
|
+
expect(preamble).not.toBeNull();
|
|
156
|
+
// Immediate parent (child-of-grandparent) gets inline detail
|
|
157
|
+
expect(preamble).toContain("child-of-grandparent");
|
|
158
|
+
expect(preamble).toContain("Second task done");
|
|
159
|
+
// Grandparent gets pointer-only entry
|
|
160
|
+
expect(preamble).toContain("grandparent-1");
|
|
161
|
+
expect(preamble).toContain("Older Ancestor Tasks");
|
|
162
|
+
expect(preamble).toContain("Initial research task");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("enforces token budget — truncates oversized output", async () => {
|
|
166
|
+
// Generate output that exceeds the budget
|
|
167
|
+
const hugeOutput = "x".repeat(CONTEXT_PREAMBLE_MAX_CHARS + 5000);
|
|
168
|
+
seedTask({
|
|
169
|
+
id: "parent-big",
|
|
170
|
+
task: "Task with very large output",
|
|
171
|
+
output: hugeOutput,
|
|
172
|
+
status: "completed",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-big");
|
|
176
|
+
expect(preamble).not.toBeNull();
|
|
177
|
+
// Preamble must be within budget (some slack for the truncation suffix)
|
|
178
|
+
expect(preamble?.length ?? 0).toBeLessThanOrEqual(
|
|
179
|
+
CONTEXT_PREAMBLE_MAX_CHARS + 300, // 300 chars slack for the truncation message
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("preamble starts with context section and ends with separator", async () => {
|
|
184
|
+
seedTask({
|
|
185
|
+
id: "parent-structure",
|
|
186
|
+
task: "A well-structured task",
|
|
187
|
+
output: "Done",
|
|
188
|
+
status: "completed",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-structure");
|
|
192
|
+
expect(preamble).toContain("---");
|
|
193
|
+
expect(preamble).toContain("Prior Conversation Context");
|
|
194
|
+
// Should end with trailing separator
|
|
195
|
+
expect(preamble?.trimEnd()).toMatch(/---\s*$/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("CONTEXT_PREAMBLE_MAX_TOKENS is 2000 by default", () => {
|
|
199
|
+
expect(CONTEXT_PREAMBLE_MAX_TOKENS).toBe(2000);
|
|
200
|
+
expect(CONTEXT_PREAMBLE_MAX_CHARS).toBe(8000);
|
|
201
|
+
});
|
|
202
|
+
});
|
package/src/tools/cancel-task.ts
CHANGED
|
@@ -142,12 +142,20 @@ export async function cancelTaskHandler(
|
|
|
142
142
|
|
|
143
143
|
if ("content" in result) return result;
|
|
144
144
|
|
|
145
|
+
const structuredContent = {
|
|
146
|
+
yourAgentId: agentId,
|
|
147
|
+
...result,
|
|
148
|
+
};
|
|
149
|
+
|
|
145
150
|
return {
|
|
146
|
-
content: [
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
content: [
|
|
152
|
+
{ type: "text", text: result.message },
|
|
153
|
+
{
|
|
154
|
+
type: "text",
|
|
155
|
+
text: JSON.stringify(structuredContent),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
structuredContent,
|
|
151
159
|
};
|
|
152
160
|
}
|
|
153
161
|
|