@desplega.ai/agent-swarm 2.5.1 → 2.5.3
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/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-gr2fcyyj.js} +3 -3
- package/dist/{actions-w8xd014k.js → actions-wv766ghf.js} +18 -18
- package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-speb906t.js} +3 -3
- package/dist/{app-nndcvftq.js → app-ag29n14f.js} +3 -3
- package/dist/{assistant-hjmqyscx.js → assistant-3x8bz3eg.js} +19 -19
- package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-5b3d2eea.js} +1 -1
- package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-jsmfrpzf.js} +1 -1
- package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-s73gerxy.js} +2 -2
- package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-5zmax2wr.js} +4 -4
- package/dist/{claude-adapter-2f8b85df.js → claude-adapter-hswdnm3t.js} +3 -3
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
- package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3k8m3hhc.js} +2 -2
- package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-fkbyk66x.js} +6905 -6855
- package/dist/{cli-y3zyaee5.js → cli-1fc85mvt.js} +5 -5
- package/dist/{cli-v2g93k4v.js → cli-210xd1f4.js} +4 -4
- package/dist/{cli-sn1vbtg2.js → cli-25j01dpj.js} +3 -3
- package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
- package/dist/{cli-r9fy0ddc.js → cli-3ngdk5te.js} +1 -1
- package/dist/{cli-z63a269p.js → cli-40jsnrjm.js} +2 -2
- package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
- package/dist/{cli-qfm6my86.js → cli-502eqv93.js} +1 -1
- package/dist/{cli-xd4eqtr2.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-hadv0725.js → cli-8s5wx3nz.js} +2 -2
- package/dist/{cli-z2dv25mf.js → cli-99xgw26r.js} +116 -5
- package/dist/{cli-jpxsrxcb.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-kcsd1ez8.js → cli-aecdcr77.js} +343 -325
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-e9btqw4y.js → cli-ce0ak9ms.js} +18 -13
- package/dist/{cli-nj12tn2c.js → cli-e2ar4bp1.js} +71 -20
- package/dist/{cli-gwcf52gz.js → cli-e8jyq7d8.js} +11 -7
- package/dist/{cli-rngde2jv.js → cli-egjggehm.js} +1 -1
- package/dist/{cli-qfjm4gvt.js → cli-ewm3pwbq.js} +2 -2
- package/dist/{cli-9mm5ade4.js → cli-fsdprwrg.js} +1 -1
- package/dist/{cli-3ee5z0q0.js → cli-fzcn3d87.js} +1 -1
- package/dist/{cli-eym3jg7w.js → cli-gpsz7g88.js} +6 -6
- package/dist/{cli-cwyafhw1.js → cli-hcpj2xqz.js} +1 -1
- package/dist/{cli-j1eghqfm.js → cli-hhxjnjzs.js} +1 -1
- package/dist/{cli-pyy4c9ch.js → cli-jjznk3f7.js} +881 -232
- package/dist/{cli-q9pzwxvk.js → cli-kxawjrxg.js} +222 -37
- package/dist/{cli-rh5smt8t.js → cli-mz9afxxf.js} +14 -3
- package/dist/{cli-mz2ygeyq.js → cli-n45mr7zh.js} +2 -2
- package/dist/{cli-fes51ya2.js → cli-p0rzjkya.js} +2 -2
- package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
- package/dist/{cli-mq7kg1b1.js → cli-p9rafdrb.js} +41 -6
- package/dist/{cli-cah5detr.js → cli-qgf8emrn.js} +5 -5
- package/dist/{cli-2wba1ac9.js → cli-qzme5949.js} +26 -26
- package/dist/{cli-znvq0k3t.js → cli-s49hpn37.js} +2 -2
- package/dist/{cli-xc65x52q.js → cli-s53vp709.js} +1 -1
- package/dist/{cli-0jy85zz4.js → cli-svrj71pg.js} +18 -16
- package/dist/{cli-1dp1zj04.js → cli-vwn81jbs.js} +3 -3
- package/dist/{cli-ajqhxtnz.js → cli-w1zwctj2.js} +1 -1
- package/dist/{cli-22rmcjkg.js → cli-y561h5a9.js} +7 -6
- package/dist/{cli-2jg61wab.js → cli-z21whrrg.js} +15 -15
- package/dist/cli.js +19 -18
- package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-n8ka66vf.js} +2 -2
- package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-fsebts37.js} +1 -1
- package/dist/{commands-mbvhbrcy.js → commands-t04rt2pc.js} +2 -2
- package/dist/{controller-gnq1k1mf.js → controller-rgna04r2.js} +2 -2
- package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-bqm1rtft.js} +20 -20
- package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-swr2k95y.js} +1 -1
- package/dist/{db-88wcymxt.js → db-24ck09xy.js} +1 -1
- package/dist/{db-af9y8nkf.js → db-a4m7haw0.js} +3 -3
- package/dist/{dispatcher-606ydx5v.js → dispatcher-nwf9ss0q.js} +17 -17
- package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-kpdnhjst.js} +2 -2
- package/dist/{external-channel-y8q5z8z2.js → external-channel-j1e2p34p.js} +2 -2
- package/dist/{grok-adapter-39aha52t.js → grok-adapter-qtjz0whk.js} +4 -4
- package/dist/{handlers-rk9dqech.js → handlers-19xr6dnz.js} +20 -20
- package/dist/{heartbeat-jfe9zfjr.js → heartbeat-vtesda5d.js} +20 -20
- package/dist/{hook-b1mez1m8.js → hook-8wqaszda.js} +1 -1
- package/dist/{hook-ffevzed1.js → hook-b2hhj6rd.js} +1 -1
- package/dist/{http-d5b44f1r.js → http-mxcqhhcs.js} +3020 -2463
- package/dist/{index-5en23s5z.js → index-1r8wfpzs.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-mj46rk9k.js → index-kw2mmat7.js} +20 -20
- package/dist/{index-nq8hhv28.js → index-n1vbvta9.js} +19 -10
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-d2qeem97.js → index-spwxjgay.js} +17 -17
- package/dist/{index-4q3vj1yr.js → index-srg522m6.js} +17 -17
- package/dist/{keepalive-wsby1btq.js → keepalive-dsd8rtgx.js} +3 -3
- package/dist/{lead-paa141j6.js → lead-zyerk1x2.js} +30 -30
- package/dist/{maintenance-vnshcyv9.js → maintenance-jqmqwnet.js} +1 -1
- package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
- package/dist/{onboard-nr4xhpzy.js → onboard-w4yd7s8t.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
- package/dist/{otel-impl-ytn9wyv9.js → otel-impl-f36byzbd.js} +1 -1
- package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-xvak5p4v.js} +3 -3
- package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-682k2mt5.js} +12 -2
- package/dist/rbac-roles-gfn30fb8.js +26 -0
- package/dist/{render-v2-pyfsg9zk.js → render-v2-hgytz8nn.js} +3 -3
- package/dist/{resume-phj8xhqr.js → resume-2dvq348g.js} +7 -5
- package/dist/{session-access-jzx2z9h5.js → session-access-7f21ekq9.js} +4 -4
- package/dist/{setup-rfg0f0sg.js → setup-epffxtbk.js} +1 -1
- package/dist/{work-objects-44z2yjg8.js → work-objects-xz1a5rqk.js} +20 -20
- package/dist/{worker-zsfqzxhm.js → worker-42t8emjt.js} +30 -30
- package/openapi.json +3163 -2114
- package/package.json +7 -6
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/session-tree.ts +158 -13
- package/src/be/db/session-visibility.ts +31 -0
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +47 -7
- package/src/be/db/workflow-runs.ts +54 -7
- package/src/be/db.ts +27 -5
- package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
- package/src/be/migrations/235_chat_relay_defer.sql +86 -0
- package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
- package/src/be/migrations/239_skills_fts.sql +40 -0
- package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
- package/src/be/migrations/241_skills_meta_fts.sql +39 -0
- package/src/be/rbac-roles.ts +138 -0
- package/src/be/scripts/run-credential.ts +64 -0
- package/src/be/scripts/run-saved.ts +3 -2
- package/src/be/swarm-config-guard.ts +23 -0
- package/src/be/users.ts +28 -4
- package/src/extensions/catalog.generated.json +4 -4
- package/src/http/all-routes.ts +1 -0
- package/src/http/approval-requests.ts +254 -43
- package/src/http/config.ts +22 -37
- package/src/http/index.ts +2 -0
- package/src/http/schedules.ts +26 -0
- package/src/http/scripts.ts +2 -2
- package/src/http/sessions.ts +118 -2
- package/src/http/user-roles.ts +173 -0
- package/src/http/users.ts +9 -0
- package/src/http/x.ts +2 -2
- package/src/integrations/chat-sdk/commands.ts +14 -1
- package/src/integrations/chat-sdk/inbound.ts +42 -6
- package/src/integrations/chat-sdk/outbound.ts +35 -10
- package/src/integrations/chat-sdk/reactions.ts +107 -0
- package/src/integrations/chat-sdk/relay.ts +75 -7
- package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
- package/src/integrations/reaction-events.ts +20 -0
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/rbac/legacy-policy.ts +3 -0
- package/src/rbac/permissions.ts +8 -0
- package/src/scheduler/schedule-task.ts +44 -3
- package/src/scheduler/scheduler.ts +4 -4
- package/src/scripts-runtime/loader.ts +8 -1
- package/src/slack/ack.ts +2 -1
- package/src/slack/reaction-shortcode.ts +4 -7
- package/src/tasks/worker-follow-up.ts +30 -0
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/approval-requests-notifications.test.ts +189 -0
- package/src/tests/chat-sdk-commands.test.ts +12 -0
- package/src/tests/chat-sdk-inbound.test.ts +183 -9
- package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
- package/src/tests/chat-sdk-relay.test.ts +361 -10
- package/src/tests/chat-sdk-tools.test.ts +104 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
- package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
- package/src/tests/http-users.test.ts +38 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/rbac-engine.test.ts +2 -0
- package/src/tests/rbac-roles.test.ts +10 -0
- package/src/tests/schedule-http-triage-tools.test.ts +44 -0
- package/src/tests/script-api-credential.test.ts +173 -0
- package/src/tests/script-connections.test.ts +10 -0
- package/src/tests/script-run-config-scope.test.ts +120 -0
- package/src/tests/script-run-token.test.ts +75 -0
- package/src/tests/scripts-http.test.ts +19 -0
- package/src/tests/sessions-tree.test.ts +410 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/user-roles-http.test.ts +204 -0
- package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/chat-reply.ts +2 -0
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/schedules/patch-schedule.ts +18 -0
- package/src/tools/schedules/update-schedule.ts +18 -0
- package/src/types.ts +23 -0
- package/src/workflows/approval-notifications.ts +221 -0
- package/src/workflows/checkpoint.ts +69 -35
- package/src/workflows/engine.ts +234 -87
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/human-in-the-loop.ts +36 -173
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/executors/swarm-script.ts +2 -2
- package/src/workflows/recovery.ts +63 -1
- package/src/workflows/resume.ts +60 -21
- package/src/workflows/retry-poller.ts +25 -4
- package/templates/extensions/comms-adherence-gate/README.md +38 -13
- package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
- package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
- package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
- package/templates/skills/code-quality/content.md +1 -1
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
- package/dist/rbac-roles-ff1ffmec.js +0 -12
- /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
- /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
4
|
+
|
|
5
|
+
const postMessage = mock(async (_args: Record<string, unknown>) => ({ ts: "1700000000.000100" }));
|
|
6
|
+
const conversationsInfo = mock(async (_args: { channel: string }) => ({
|
|
7
|
+
channel: { is_ext_shared: false },
|
|
8
|
+
}));
|
|
9
|
+
mock.module("../slack/app", () => ({
|
|
10
|
+
getSlackApp: () => ({
|
|
11
|
+
client: { chat: { postMessage }, conversations: { info: conversationsInfo } },
|
|
12
|
+
}),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
closeDb,
|
|
17
|
+
createAgent,
|
|
18
|
+
getApprovalRequestById,
|
|
19
|
+
initDb,
|
|
20
|
+
listApprovalRequests,
|
|
21
|
+
} from "../be/db";
|
|
22
|
+
import { handleApprovalRequests } from "../http/approval-requests";
|
|
23
|
+
import { resetExternalChannelStateForTesting } from "../slack/external-channel";
|
|
24
|
+
import { listenOnFreePort } from "./test-net";
|
|
25
|
+
|
|
26
|
+
const TEST_DB_PATH = "./test-approval-requests-notifications.sqlite";
|
|
27
|
+
|
|
28
|
+
let server: Server;
|
|
29
|
+
let baseUrl = "";
|
|
30
|
+
let leadId = "";
|
|
31
|
+
let workerId = "";
|
|
32
|
+
|
|
33
|
+
function postRequest(body: Record<string, unknown>, agentId: string | null): Promise<Response> {
|
|
34
|
+
return fetch(`${baseUrl}/api/approval-requests`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
...(agentId ? { "X-Agent-ID": agentId } : {}),
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
title: "Ship it?",
|
|
42
|
+
questions: [{ id: "q1", type: "approval", label: "Approve the deploy?" }],
|
|
43
|
+
approvers: { policy: "any" },
|
|
44
|
+
...body,
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function createRequest(body: Record<string, unknown>, agentId = leadId): Promise<string> {
|
|
50
|
+
const res = await postRequest(body, agentId);
|
|
51
|
+
expect(res.status).toBe(201);
|
|
52
|
+
const json = (await res.json()) as { approvalRequest: { id: string } };
|
|
53
|
+
return json.approvalRequest.id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function waitFor(check: () => Promise<boolean>): Promise<void> {
|
|
57
|
+
for (let i = 0; i < 100; i++) {
|
|
58
|
+
if (await check()) return;
|
|
59
|
+
await Bun.sleep(10);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("POST /api/approval-requests notifications", () => {
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
try {
|
|
66
|
+
await unlink(TEST_DB_PATH);
|
|
67
|
+
} catch {}
|
|
68
|
+
initDb(TEST_DB_PATH);
|
|
69
|
+
leadId = (await createAgent({ name: "notify-lead", isLead: true, status: "idle" })).id;
|
|
70
|
+
workerId = (await createAgent({ name: "notify-worker", isLead: false, status: "idle" })).id;
|
|
71
|
+
server = createHttpServer(async (req, res) => {
|
|
72
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
73
|
+
const handled = await handleApprovalRequests(
|
|
74
|
+
req,
|
|
75
|
+
res,
|
|
76
|
+
url.pathname.split("/").filter(Boolean),
|
|
77
|
+
url.searchParams,
|
|
78
|
+
);
|
|
79
|
+
if (!handled) {
|
|
80
|
+
res.writeHead(404);
|
|
81
|
+
res.end();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
89
|
+
closeDb();
|
|
90
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
91
|
+
try {
|
|
92
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
postMessage.mockClear();
|
|
99
|
+
conversationsInfo.mockClear();
|
|
100
|
+
resetExternalChannelStateForTesting();
|
|
101
|
+
delete process.env.SLACK_BLOCK_EXTERNAL_CHANNELS;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("posts one Slack card and stores its messageTs", async () => {
|
|
105
|
+
const id = await createRequest({
|
|
106
|
+
title: "Ship <!channel> & go?",
|
|
107
|
+
timeoutSeconds: 3600,
|
|
108
|
+
notifications: [{ channel: "slack", target: "C123" }],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await waitFor(async () => {
|
|
112
|
+
const row = await getApprovalRequestById(id);
|
|
113
|
+
const channels = row?.notificationChannels as Array<{ messageTs?: string }> | null;
|
|
114
|
+
return Boolean(channels?.[0]?.messageTs);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
118
|
+
const args = postMessage.mock.calls[0]![0];
|
|
119
|
+
expect(args.channel).toBe("C123");
|
|
120
|
+
expect(args.text).toContain(`/approval-requests/${id}`);
|
|
121
|
+
const blocks = JSON.stringify(args.blocks);
|
|
122
|
+
expect(blocks).toContain("Approval Required: Ship <!channel> & go?");
|
|
123
|
+
expect(blocks).not.toContain("<!channel>");
|
|
124
|
+
expect(blocks).toContain("Approve the deploy?");
|
|
125
|
+
expect(blocks).toContain("Review & Respond");
|
|
126
|
+
expect(blocks).toContain("times out if not responded");
|
|
127
|
+
|
|
128
|
+
const row = await getApprovalRequestById(id);
|
|
129
|
+
expect(row?.notificationChannels).toEqual([
|
|
130
|
+
{ channel: "slack", target: "C123", messageTs: "1700000000.000100" },
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("posts nothing without notifications", async () => {
|
|
135
|
+
const id = await createRequest({});
|
|
136
|
+
await Bun.sleep(50);
|
|
137
|
+
|
|
138
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
139
|
+
expect((await getApprovalRequestById(id))?.notificationChannels).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("skips email targets without posting", async () => {
|
|
143
|
+
const id = await createRequest({
|
|
144
|
+
notifications: [{ channel: "email", target: "ops@example.com" }],
|
|
145
|
+
});
|
|
146
|
+
await Bun.sleep(50);
|
|
147
|
+
|
|
148
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
149
|
+
expect((await getApprovalRequestById(id))?.notificationChannels).toEqual([
|
|
150
|
+
{ channel: "email", target: "ops@example.com" },
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("refuses Slack notifications from a non-lead caller and creates nothing", async () => {
|
|
155
|
+
for (const agentId of [workerId, null]) {
|
|
156
|
+
const before = (await listApprovalRequests()).length;
|
|
157
|
+
const res = await postRequest(
|
|
158
|
+
{ notifications: [{ channel: "slack", target: "C123" }] },
|
|
159
|
+
agentId,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(res.status).toBe(403);
|
|
163
|
+
expect((await listApprovalRequests()).length).toBe(before);
|
|
164
|
+
}
|
|
165
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("lets a non-lead caller create a request without Slack notifications", async () => {
|
|
169
|
+
await createRequest(
|
|
170
|
+
{ notifications: [{ channel: "email", target: "ops@example.com" }] },
|
|
171
|
+
workerId,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("skips a channel shared with another org when the external-channel block is on", async () => {
|
|
176
|
+
process.env.SLACK_BLOCK_EXTERNAL_CHANNELS = "true";
|
|
177
|
+
conversationsInfo.mockImplementationOnce(async () => ({ channel: { is_ext_shared: true } }));
|
|
178
|
+
|
|
179
|
+
const id = await createRequest({ notifications: [{ channel: "slack", target: "CEXT" }] });
|
|
180
|
+
await waitFor(async () => conversationsInfo.mock.calls.length > 0);
|
|
181
|
+
await Bun.sleep(50);
|
|
182
|
+
|
|
183
|
+
expect(conversationsInfo).toHaveBeenCalledWith({ channel: "CEXT" });
|
|
184
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
185
|
+
expect((await getApprovalRequestById(id))?.notificationChannels).toEqual([
|
|
186
|
+
{ channel: "slack", target: "CEXT" },
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -84,6 +84,18 @@ describe("Chat SDK slash commands", () => {
|
|
|
84
84
|
expect(await chatTasks(3001)).toHaveLength(0);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
test("/start in a fresh DM answers with the welcome and stays out of the / menu", async () => {
|
|
88
|
+
await postTelegramUpdate(commandUpdate(dm(3003), "/start"));
|
|
89
|
+
const texts = sentTexts(3003);
|
|
90
|
+
expect(texts).toHaveLength(1);
|
|
91
|
+
expect(texts[0]).toContain("Each answer arrives as a reply to your message.");
|
|
92
|
+
expect(texts[0]).toContain("/help");
|
|
93
|
+
expect(texts[0]).not.toContain("Unknown command");
|
|
94
|
+
expect(CHAT_COMMANDS.map((c) => c.command)).not.toContain("start");
|
|
95
|
+
await flushChatBuffers();
|
|
96
|
+
expect(await chatTasks(3003)).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
87
99
|
test("an unknown command gets the list instead of silence", async () => {
|
|
88
100
|
await postTelegramUpdate(commandUpdate(dm(3002), "/deploy"));
|
|
89
101
|
const texts = sentTexts(3002);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
closeDb,
|
|
8
8
|
completeTask,
|
|
9
9
|
createAgent,
|
|
10
|
+
createScheduledTask,
|
|
10
11
|
createTaskExtended,
|
|
11
12
|
createUser,
|
|
12
13
|
getDbClient,
|
|
@@ -20,10 +21,12 @@ import {
|
|
|
20
21
|
import { linkIdentity } from "../be/users";
|
|
21
22
|
import { resetFileStorageProviderForTests } from "../fs/registry";
|
|
22
23
|
import { drainBackground } from "../integrations/chat-sdk/background";
|
|
23
|
-
import {
|
|
24
|
+
import { CHAT_CHANNEL_SETTINGS_NAMESPACE } from "../integrations/chat-sdk/reactions";
|
|
25
|
+
import { formatChatRelayText, formatChatTaskTag } from "../integrations/chat-sdk/relay";
|
|
24
26
|
import { CHAT_CHANNELS_NAMESPACE, routeChatMessage } from "../integrations/chat-sdk/router";
|
|
25
27
|
import { getChatInstance } from "../integrations/chat-sdk/runtime";
|
|
26
28
|
import { isChatThreadBuffered } from "../integrations/chat-sdk/thread-buffer";
|
|
29
|
+
import { createStandaloneScheduleTask } from "../scheduler/schedule-task";
|
|
27
30
|
import { chatSdkContextKey } from "../tasks/context-key";
|
|
28
31
|
import { registerDeferTaskTool } from "../tools/defer-task";
|
|
29
32
|
import type { Agent } from "../types";
|
|
@@ -144,6 +147,25 @@ function buildDeferTaskTool(): DeferTaskTool {
|
|
|
144
147
|
const dm = (id: number): TelegramChat => ({ id, type: "private" });
|
|
145
148
|
const group = (id: number): TelegramChat => ({ id, type: "group" });
|
|
146
149
|
|
|
150
|
+
const messageIdOf = (update: Record<string, unknown>) =>
|
|
151
|
+
(update.message as { message_id: number }).message_id;
|
|
152
|
+
|
|
153
|
+
/** Every `setMessageReaction` call on one message, as the emoji it set (`""` = cleared). */
|
|
154
|
+
function reactionsOn(chatId: number, update: Record<string, unknown>): string[] {
|
|
155
|
+
return harness.mock.calls
|
|
156
|
+
.filter((call) => call.method === "setMessageReaction")
|
|
157
|
+
.map((call) => call.body as { chat_id: unknown; message_id: unknown; reaction: unknown })
|
|
158
|
+
.filter(
|
|
159
|
+
(body) =>
|
|
160
|
+
String(body.chat_id) === String(chatId) && Number(body.message_id) === messageIdOf(update),
|
|
161
|
+
)
|
|
162
|
+
.map((body) => {
|
|
163
|
+
const reaction =
|
|
164
|
+
typeof body.reaction === "string" ? JSON.parse(body.reaction) : body.reaction;
|
|
165
|
+
return (reaction as Array<{ emoji?: string }>).map((r) => r.emoji ?? "").join("");
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
147
169
|
describe("Chat SDK inbound pipeline", () => {
|
|
148
170
|
test("two messages inside the buffer window become one task", async () => {
|
|
149
171
|
await postTelegramUpdate(telegramUpdate(dm(2001), "first part of the request"));
|
|
@@ -233,19 +255,121 @@ describe("Chat SDK inbound pipeline", () => {
|
|
|
233
255
|
first!.id,
|
|
234
256
|
]);
|
|
235
257
|
|
|
236
|
-
const
|
|
237
|
-
/^send(Rich)?Message$/.test(call.method)
|
|
238
|
-
)
|
|
239
|
-
|
|
258
|
+
const sendCount = () =>
|
|
259
|
+
harness.mock.calls.filter((call) => /^send(Rich)?Message$/.test(call.method)).length;
|
|
260
|
+
const sendsBefore = sendCount();
|
|
261
|
+
const steer = telegramUpdate(group(-2005), "also add the charts");
|
|
262
|
+
await postTelegramUpdate(steer);
|
|
240
263
|
await flushChatBuffers();
|
|
241
264
|
|
|
242
265
|
expect(await chatTasks(-2005)).toHaveLength(1);
|
|
243
266
|
expect(await steeringRows(first!.id)).toEqual([
|
|
244
267
|
{ body: "also add the charts", source: "chat" },
|
|
245
268
|
]);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
).toBe(
|
|
269
|
+
// A reaction acknowledges the steering message; no text reply.
|
|
270
|
+
expect(reactionsOn(-2005, steer)).toEqual(["✍"]);
|
|
271
|
+
expect(sendCount()).toBe(sendsBefore);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a channel that opts into text acks also gets the steering reply", async () => {
|
|
275
|
+
await upsertKv({
|
|
276
|
+
namespace: CHAT_CHANNEL_SETTINGS_NAMESPACE,
|
|
277
|
+
key: "telegram:-2015",
|
|
278
|
+
value: { textAcks: true },
|
|
279
|
+
valueType: "json",
|
|
280
|
+
});
|
|
281
|
+
await postTelegramUpdate(telegramUpdate(group(-2015), "@swarm_test_bot draft the report"));
|
|
282
|
+
await flushChatBuffers();
|
|
283
|
+
const [first] = await chatTasks(-2015);
|
|
284
|
+
await getDbClient().run("UPDATE agent_tasks SET status = 'in_progress' WHERE id = ?", [
|
|
285
|
+
first!.id,
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
const steer = telegramUpdate(group(-2015), "also add the charts");
|
|
289
|
+
await postTelegramUpdate(steer);
|
|
290
|
+
|
|
291
|
+
expect(reactionsOn(-2015, steer)).toEqual(["✍"]);
|
|
292
|
+
const sends = harness.mock.calls.filter((call) => /^send(Rich)?Message$/.test(call.method));
|
|
293
|
+
// The acknowledgement replies to the steering message.
|
|
294
|
+
expect(sends.at(-1)?.body.reply_parameters).toEqual({
|
|
295
|
+
message_id: messageIdOf(steer),
|
|
296
|
+
allow_sending_without_reply: true,
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("an ask gets one reaction at a time; earlier buffered messages are cleared at flush", async () => {
|
|
301
|
+
const firstPart = telegramUpdate(dm(2016), "first part");
|
|
302
|
+
const secondPart = telegramUpdate(dm(2016), "second part");
|
|
303
|
+
await postTelegramUpdate(firstPart);
|
|
304
|
+
expect(reactionsOn(2016, firstPart)).toEqual(["👀"]);
|
|
305
|
+
await postTelegramUpdate(secondPart);
|
|
306
|
+
await flushChatBuffers();
|
|
307
|
+
|
|
308
|
+
expect(await chatTasks(2016)).toHaveLength(1);
|
|
309
|
+
// The task answers the newest message, which keeps 👀; the earlier one is cleared.
|
|
310
|
+
expect(reactionsOn(2016, secondPart)).toEqual(["👀"]);
|
|
311
|
+
expect(reactionsOn(2016, firstPart)).toEqual(["👀", ""]);
|
|
312
|
+
|
|
313
|
+
const quick = telegramUpdate(dm(2017), "!now ship it");
|
|
314
|
+
await postTelegramUpdate(quick);
|
|
315
|
+
expect(reactionsOn(2017, quick)).toEqual(["⚡"]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("a REACTION_INVALID answer is logged and the ask still becomes a task", async () => {
|
|
319
|
+
const warn = spyOn(console, "warn");
|
|
320
|
+
harness.mock.failReactions(1);
|
|
321
|
+
try {
|
|
322
|
+
const ask = telegramUpdate(dm(2018), "!now plan the sprint");
|
|
323
|
+
await postTelegramUpdate(ask);
|
|
324
|
+
expect(await chatTasks(2018)).toHaveLength(1);
|
|
325
|
+
expect(reactionsOn(2018, ask)).toHaveLength(1);
|
|
326
|
+
expect(
|
|
327
|
+
warn.mock.calls.some((args) => String(args[0]).includes("Could not set the now reaction")),
|
|
328
|
+
).toBe(true);
|
|
329
|
+
} finally {
|
|
330
|
+
harness.mock.failReactions(0);
|
|
331
|
+
warn.mockRestore();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("a mention in a subscribed group gets 👀 before the flush, and the flush does not react again", async () => {
|
|
336
|
+
await postTelegramUpdate(telegramUpdate(group(-2991), "@swarm_test_bot first ask"));
|
|
337
|
+
await flushChatBuffers();
|
|
338
|
+
const ask = telegramUpdate(group(-2991), "@swarm_test_bot second ask");
|
|
339
|
+
await postTelegramUpdate(ask);
|
|
340
|
+
expect(reactionsOn(-2991, ask)).toEqual(["👀"]);
|
|
341
|
+
await flushChatBuffers();
|
|
342
|
+
expect(await chatTasks(-2991)).toHaveLength(2);
|
|
343
|
+
expect(reactionsOn(-2991, ask)).toEqual(["👀"]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("a !now mention in a subscribed group gets ⚡, not 👀", async () => {
|
|
347
|
+
await postTelegramUpdate(telegramUpdate(group(-2992), "@swarm_test_bot first ask"));
|
|
348
|
+
await flushChatBuffers();
|
|
349
|
+
const ask = telegramUpdate(group(-2992), "@swarm_test_bot !now second ask");
|
|
350
|
+
await postTelegramUpdate(ask);
|
|
351
|
+
expect(await chatTasks(-2992)).toHaveLength(2);
|
|
352
|
+
expect(reactionsOn(-2992, ask)).toEqual(["⚡"]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("two asks back to back each anchor to their own message, not the parent's", async () => {
|
|
356
|
+
const firstAsk = telegramUpdate(group(-2010), "@swarm_test_bot !now summarize the PR");
|
|
357
|
+
await postTelegramUpdate(firstAsk);
|
|
358
|
+
const secondAsk = telegramUpdate(group(-2010), "@swarm_test_bot !now and list the risks");
|
|
359
|
+
await postTelegramUpdate(secondAsk);
|
|
360
|
+
await flushChatBuffers();
|
|
361
|
+
|
|
362
|
+
const rows = await chatTasks(-2010);
|
|
363
|
+
expect(rows).toHaveLength(2);
|
|
364
|
+
const [first, second] = await Promise.all(rows.map((row) => getTaskById(row.id)));
|
|
365
|
+
const idOf = (update: Record<string, unknown>) =>
|
|
366
|
+
`-2010:${(update.message as { message_id: number }).message_id}`;
|
|
367
|
+
expect(first?.mentionMessageId).toBe(idOf(firstAsk));
|
|
368
|
+
// The second task is a child of the first; `createTask` would copy the
|
|
369
|
+
// parent's anchor if inbound did not set its own.
|
|
370
|
+
expect(second?.parentTaskId).toBe(first!.id);
|
|
371
|
+
expect(second?.mentionMessageId).toBe(idOf(secondAsk));
|
|
372
|
+
expect(second?.mentionChannelId).toBeTruthy();
|
|
249
373
|
});
|
|
250
374
|
|
|
251
375
|
test("a Telegram private-chat DM steers too (DMs never reach onSubscribedMessage)", async () => {
|
|
@@ -282,6 +406,52 @@ describe("Chat SDK inbound pipeline", () => {
|
|
|
282
406
|
expect(followUp?.task).toContain("include the changelog");
|
|
283
407
|
});
|
|
284
408
|
|
|
409
|
+
test("a deferred wake-up is the thread's chat task: steering, a follow-up dependency and /cancel reach it", async () => {
|
|
410
|
+
await postTelegramUpdate(telegramUpdate(dm(2040), "check the deploy"));
|
|
411
|
+
await flushChatBuffers();
|
|
412
|
+
const [first] = await chatTasks(2040);
|
|
413
|
+
await completeTask(first!.id, "Deferred.", { deferredAt: new Date().toISOString() });
|
|
414
|
+
const schedule = await createScheduledTask({
|
|
415
|
+
name: `deferred-${first!.id.slice(0, 8)}-${crypto.randomUUID()}`,
|
|
416
|
+
taskTemplate: "Check the deploy and answer.",
|
|
417
|
+
targetType: "agent-task",
|
|
418
|
+
scheduleType: "one_time",
|
|
419
|
+
nextRunAt: new Date().toISOString(),
|
|
420
|
+
targetAgentId: lead.id,
|
|
421
|
+
createdByAgentId: lead.id,
|
|
422
|
+
taskType: "deferred",
|
|
423
|
+
parentTaskId: first!.id,
|
|
424
|
+
});
|
|
425
|
+
const wakeUp = await createStandaloneScheduleTask(schedule);
|
|
426
|
+
expect(wakeUp.source).toBe("schedule");
|
|
427
|
+
expect(wakeUp.contextKey).toBe(chatSdkContextKey("telegram:2040"));
|
|
428
|
+
|
|
429
|
+
// In progress: a plain message steers the wake-up.
|
|
430
|
+
await startTask(wakeUp.id);
|
|
431
|
+
await postTelegramUpdate(telegramUpdate(dm(2040), "use the staging cluster"));
|
|
432
|
+
await flushChatBuffers();
|
|
433
|
+
expect(await chatTasks(2040)).toHaveLength(1);
|
|
434
|
+
expect(await steeringRows(wakeUp.id)).toEqual([
|
|
435
|
+
{ body: "use the staging cluster", source: "chat" },
|
|
436
|
+
]);
|
|
437
|
+
|
|
438
|
+
// Pending: a new message becomes a follow-up that waits for it.
|
|
439
|
+
await getDbClient().run("UPDATE agent_tasks SET status = 'pending' WHERE id = ?", [wakeUp.id]);
|
|
440
|
+
await postTelegramUpdate(telegramUpdate(dm(2040), "and the canary"));
|
|
441
|
+
await flushChatBuffers();
|
|
442
|
+
const tasks = await chatTasks(2040);
|
|
443
|
+
expect(tasks).toHaveLength(2);
|
|
444
|
+
expect((await getTaskById(tasks[1]!.id))?.dependsOn).toEqual([wakeUp.id]);
|
|
445
|
+
|
|
446
|
+
// /cancel stops the follow-up and the wake-up.
|
|
447
|
+
const cancel = telegramUpdate(dm(2040), "/cancel", {
|
|
448
|
+
entities: [{ type: "bot_command", offset: 0, length: "/cancel".length }],
|
|
449
|
+
});
|
|
450
|
+
await postTelegramUpdate(cancel);
|
|
451
|
+
expect((await getTaskById(wakeUp.id))?.status).toBe("cancelled");
|
|
452
|
+
expect((await getTaskById(tasks[1]!.id))?.status).toBe("cancelled");
|
|
453
|
+
});
|
|
454
|
+
|
|
285
455
|
test("a document attachment becomes a task attachment and a [File: ...] line", async () => {
|
|
286
456
|
const bytes = new TextEncoder().encode("meeting notes\n");
|
|
287
457
|
harness.mock.addFile("documents/notes.txt", bytes);
|
|
@@ -451,7 +621,11 @@ describe("Chat SDK requester and group addressing", () => {
|
|
|
451
621
|
const deferred = await getTaskById(task!.id);
|
|
452
622
|
expect(deferred?.output).toMatch(new RegExp(`at ${madrid}$`));
|
|
453
623
|
expect(deferred?.output).not.toContain("UTC");
|
|
454
|
-
|
|
624
|
+
// The relay posts the placeholder text after the task tag (here the trigger
|
|
625
|
+
// drops it: a deferral posts nothing).
|
|
626
|
+
expect(formatChatRelayText(deferred!)).toBe(
|
|
627
|
+
`${formatChatTaskTag(deferred!)}\n\n${deferred!.output!}`,
|
|
628
|
+
);
|
|
455
629
|
});
|
|
456
630
|
|
|
457
631
|
test("an unknown sender gets no requester and lands in the Unmapped tracker", async () => {
|
|
@@ -25,7 +25,11 @@ import {
|
|
|
25
25
|
import { recordTaskAttachmentUpload } from "../be/task-attachment-store";
|
|
26
26
|
import { getFileStorageProvider, resetFileStorageProviderForTests } from "../fs/registry";
|
|
27
27
|
import { formatUnsentFilesNote, telegramAttachmentType } from "../integrations/chat-sdk/outbound";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
formatChatTaskTag,
|
|
30
|
+
processPendingChatRelays,
|
|
31
|
+
stopChatRelayWatcher,
|
|
32
|
+
} from "../integrations/chat-sdk/relay";
|
|
29
33
|
import { chatSdkContextKey } from "../tasks/context-key";
|
|
30
34
|
import { registerChatPostTool } from "../tools/chat-post";
|
|
31
35
|
import { registerChatReplyTool } from "../tools/chat-reply";
|
|
@@ -646,7 +650,10 @@ describe("relay of output attachments", () => {
|
|
|
646
650
|
expect.stringMatching(/^send(Rich)?Message$/),
|
|
647
651
|
"sendDocument",
|
|
648
652
|
]);
|
|
649
|
-
|
|
653
|
+
// The line-leading tag is escaped so Telegram does not render it as a heading.
|
|
654
|
+
expect(textOf(calls[0]!.body)).toBe(
|
|
655
|
+
`\\${formatChatTaskTag(task, lead.name)}\n\nDone, report attached.`,
|
|
656
|
+
);
|
|
650
657
|
expect((calls[1]!.body.document as TelegramMockFilePart).filename).toBe("result.pdf");
|
|
651
658
|
});
|
|
652
659
|
});
|