@desplega.ai/agent-swarm 2.5.0 → 2.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{acp-adapter-11gp67nr.js → acp-adapter-csh0zz3b.js} +1 -1
- package/dist/{actions-8p2kze8j.js → actions-6xx079d1.js} +17 -17
- package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-8n6f0hz8.js} +2 -2
- package/dist/{app-0qp0a2gk.js → app-ft6r6v23.js} +3 -3
- package/dist/{assistant-jedd3ktm.js → assistant-vt2cyfzd.js} +18 -18
- package/dist/{boot-reembed-n4wq5dd2.js → boot-reembed-r3e808ky.js} +1 -1
- package/dist/{boot-reembed-4xwq5tg0.js → boot-reembed-tgrfy27t.js} +1 -1
- package/dist/{boot-scrub-sweep-69h90xcq.js → boot-scrub-sweep-masdwxkm.js} +2 -2
- package/dist/{boot-seeds-3vn0m9sg.js → boot-seeds-0n82qc7q.js} +4 -4
- package/dist/{claude-adapter-221mvad5.js → claude-adapter-s7j47k06.js} +2 -2
- package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3hzrgrnk.js} +1 -1
- package/dist/{claude-sdk-session-8thjsk5e.js → claude-sdk-session-0zrdzyx2.js} +6905 -6855
- package/dist/{cli-y9jvxfhk.js → cli-0vgy5wfw.js} +2 -2
- package/dist/{cli-bgdtv62r.js → cli-14v1sxxy.js} +6 -6
- package/dist/{cli-fy1jyyym.js → cli-15xd0j7w.js} +18 -13
- package/dist/{cli-36j4jnk3.js → cli-3e9vs93g.js} +4 -4
- package/dist/{cli-5rvg1ddk.js → cli-4qkg9968.js} +3 -3
- package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
- package/dist/{cli-s4sntwt4.js → cli-6y43v79h.js} +1 -1
- package/dist/{cli-z63a269p.js → cli-7ccf7akh.js} +1 -1
- package/dist/{cli-0z3vjrpm.js → cli-86zpfje5.js} +116 -5
- package/dist/{cli-fes51ya2.js → cli-aaqnqpyw.js} +2 -2
- package/dist/{cli-mbfj11fq.js → cli-b1tv22wr.js} +1 -1
- package/dist/{cli-ftmq4nvk.js → cli-b63d08rs.js} +1 -1
- package/dist/{cli-xt768r5m.js → cli-b6tnjdvx.js} +11 -7
- package/dist/{cli-0y6f0ckq.js → cli-bpbcpd8b.js} +1 -1
- package/dist/{cli-sy8f9705.js → cli-cf63dnxj.js} +2 -2
- package/dist/{cli-fcc5qe3f.js → cli-csh8fnbg.js} +82 -16
- package/dist/{cli-x6vf4sad.js → cli-cyfgxnvb.js} +7 -7
- package/dist/{cli-9ce2xw98.js → cli-d172svcw.js} +5 -5
- package/dist/{cli-we0ra057.js → cli-e91vshb1.js} +41 -6
- package/dist/{cli-segs7hx9.js → cli-edtm741g.js} +5 -5
- package/dist/{cli-x21dqn18.js → cli-f3pfn4x3.js} +1 -1
- package/dist/{cli-nnvjb17s.js → cli-f55b6ky0.js} +1 -1
- package/dist/{cli-1pr672hz.js → cli-fgn7yg5r.js} +1 -1
- package/dist/{cli-5kzk3472.js → cli-h5d22pfs.js} +287 -78
- package/dist/{cli-r25qpaxt.js → cli-ha22h0eg.js} +20 -20
- package/dist/{cli-92mqenkc.js → cli-j3dwd4kz.js} +173 -134
- package/dist/{cli-j1eghqfm.js → cli-jmcjbjkt.js} +1 -1
- package/dist/{cli-179xcyky.js → cli-kcnwn85g.js} +4 -3
- package/dist/{cli-rh5smt8t.js → cli-mntxtkhn.js} +12 -2
- package/dist/{cli-7g40g8hq.js → cli-n2918sed.js} +9 -9
- package/dist/{cli-y4x8ww6s.js → cli-qrjv7t18.js} +1 -1
- package/dist/{cli-7bwtc6yx.js → cli-vaxw3bg0.js} +3 -3
- package/dist/{cli-4w24f7z3.js → cli-wadzapwb.js} +1 -1
- package/dist/{cli-jpxsrxcb.js → cli-xw9q173h.js} +1 -1
- package/dist/{cli-xawhpgc9.js → cli-xzge46sj.js} +2 -2
- package/dist/{cli-3zwye9sn.js → cli-y3dzwyy3.js} +3 -3
- package/dist/{cli-xd4eqtr2.js → cli-ydg6ep51.js} +1 -1
- package/dist/cli.js +16 -15
- package/dist/{codex-adapter-tcg1q3jk.js → codex-adapter-brh2tcxr.js} +1 -1
- package/dist/{codex-session-runner-xfx4wd9x.js → codex-session-runner-0dnj21qs.js} +1 -1
- package/dist/{commands-ew83nj70.js → commands-1cnvseqx.js} +2 -2
- package/dist/{controller-gnq1k1mf.js → controller-kc7s9xhn.js} +2 -2
- package/dist/{controllers-connect-tw1dan9r.js → controllers-connect-jzx7t4bn.js} +18 -18
- package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-qpwp5sfg.js} +1 -1
- package/dist/{db-t86xrnes.js → db-4vq88jkc.js} +3 -3
- package/dist/{db-7gbc53fd.js → db-v5ytg5pw.js} +1 -1
- package/dist/{dispatcher-64bdyhps.js → dispatcher-trtcrqae.js} +16 -16
- package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-f20xwn1z.js} +1 -1
- package/dist/{external-channel-e9nw24zv.js → external-channel-n9bymc2n.js} +2 -2
- package/dist/{grok-adapter-v82srhrx.js → grok-adapter-4kpmmnr6.js} +2 -2
- package/dist/{handlers-c9ajzhbn.js → handlers-ptsraqa8.js} +19 -19
- package/dist/{heartbeat-2vqs1319.js → heartbeat-60qtxtjf.js} +19 -19
- package/dist/{hook-01nk13wx.js → hook-ngtvh729.js} +1 -1
- package/dist/{hook-jzpheegg.js → hook-rbktcstg.js} +1 -1
- package/dist/{http-fc1k6ec8.js → http-z8xy72gp.js} +334 -86
- package/dist/{index-zdycv3ja.js → index-06qkmcx6.js} +10 -9
- package/dist/{index-07ef7nkm.js → index-1cscynkb.js} +16 -16
- package/dist/{index-sepja052.js → index-a9nsavhx.js} +1 -1
- package/dist/{index-q3t40ryv.js → index-r70q0xjx.js} +16 -16
- package/dist/{index-7yts8n9s.js → index-s193j5fh.js} +19 -19
- package/dist/{keepalive-5nh2rhmr.js → keepalive-a9srcphj.js} +3 -3
- package/dist/{lead-aayq64jn.js → lead-3devneqy.js} +26 -26
- package/dist/{maintenance-hjhzdt3k.js → maintenance-0er16h2y.js} +1 -1
- package/dist/{oauth-refresh-sweep-y3n1knmd.js → oauth-refresh-sweep-b65jcyw2.js} +2 -2
- package/dist/{onboard-1zq3m7db.js → onboard-wqywz5py.js} +2 -2
- package/dist/{otel-impl-h9yyza47.js → otel-impl-vk32s884.js} +1 -1
- package/dist/{pricing-refresh-nevhat8h.js → pricing-refresh-t27anv4z.js} +3 -3
- package/dist/rbac-roles-452665y0.js +26 -0
- package/dist/{rbac-roles-4y6ngvzq.js → rbac-roles-bmcgsxjn.js} +12 -2
- package/dist/{render-v2-cfsh13z3.js → render-v2-0ttsk2j4.js} +3 -3
- package/dist/{resume-5s2t0dbs.js → resume-a26kvr41.js} +5 -5
- package/dist/{session-access-dnbyp3cn.js → session-access-dgh9nkwk.js} +4 -4
- package/dist/{setup-1wj1y77j.js → setup-9vc9qahm.js} +1 -1
- package/dist/{work-objects-7mg6216g.js → work-objects-vn8nkxwn.js} +19 -19
- package/dist/{worker-nwb789mg.js → worker-1x1h8vnm.js} +26 -26
- package/openapi.json +463 -3
- package/package.json +4 -3
- package/src/be/db/session-tree.ts +89 -2
- package/src/be/db/session-visibility.ts +31 -0
- package/src/be/db/tasks/read.ts +18 -5
- package/src/be/db.ts +2 -0
- 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/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/users.ts +28 -4
- package/src/gitlab/auth.ts +8 -2
- package/src/gitlab/handlers.ts +60 -12
- package/src/gitlab/index.ts +1 -1
- package/src/http/all-routes.ts +1 -0
- package/src/http/approval-requests.ts +46 -6
- 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 +93 -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 +40 -5
- 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/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-requests-notifications.test.ts +189 -0
- package/src/tests/chat-sdk-commands.test.ts +12 -0
- package/src/tests/chat-sdk-inbound.test.ts +163 -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/gitlab-auth.test.ts +13 -5
- package/src/tests/gitlab-handlers.test.ts +270 -22
- package/src/tests/http-users.test.ts +38 -0
- package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -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 +4 -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 +242 -0
- package/src/tests/user-roles-http.test.ts +204 -0
- package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
- package/src/tools/chat-reply.ts +2 -0
- package/src/tools/schedules/patch-schedule.ts +18 -0
- package/src/tools/schedules/update-schedule.ts +18 -0
- package/src/types.ts +16 -0
- package/src/workflows/approval-notifications.ts +214 -0
- package/src/workflows/executors/human-in-the-loop.ts +11 -172
- package/src/workflows/executors/swarm-script.ts +2 -2
- package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
- package/templates/skills/code-quality/content.md +1 -1
- package/dist/rbac-roles-exbg8xb6.js +0 -12
|
@@ -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,101 @@ 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("two asks back to back each anchor to their own message, not the parent's", async () => {
|
|
336
|
+
const firstAsk = telegramUpdate(group(-2010), "@swarm_test_bot !now summarize the PR");
|
|
337
|
+
await postTelegramUpdate(firstAsk);
|
|
338
|
+
const secondAsk = telegramUpdate(group(-2010), "@swarm_test_bot !now and list the risks");
|
|
339
|
+
await postTelegramUpdate(secondAsk);
|
|
340
|
+
await flushChatBuffers();
|
|
341
|
+
|
|
342
|
+
const rows = await chatTasks(-2010);
|
|
343
|
+
expect(rows).toHaveLength(2);
|
|
344
|
+
const [first, second] = await Promise.all(rows.map((row) => getTaskById(row.id)));
|
|
345
|
+
const idOf = (update: Record<string, unknown>) =>
|
|
346
|
+
`-2010:${(update.message as { message_id: number }).message_id}`;
|
|
347
|
+
expect(first?.mentionMessageId).toBe(idOf(firstAsk));
|
|
348
|
+
// The second task is a child of the first; `createTask` would copy the
|
|
349
|
+
// parent's anchor if inbound did not set its own.
|
|
350
|
+
expect(second?.parentTaskId).toBe(first!.id);
|
|
351
|
+
expect(second?.mentionMessageId).toBe(idOf(secondAsk));
|
|
352
|
+
expect(second?.mentionChannelId).toBeTruthy();
|
|
249
353
|
});
|
|
250
354
|
|
|
251
355
|
test("a Telegram private-chat DM steers too (DMs never reach onSubscribedMessage)", async () => {
|
|
@@ -282,6 +386,52 @@ describe("Chat SDK inbound pipeline", () => {
|
|
|
282
386
|
expect(followUp?.task).toContain("include the changelog");
|
|
283
387
|
});
|
|
284
388
|
|
|
389
|
+
test("a deferred wake-up is the thread's chat task: steering, a follow-up dependency and /cancel reach it", async () => {
|
|
390
|
+
await postTelegramUpdate(telegramUpdate(dm(2040), "check the deploy"));
|
|
391
|
+
await flushChatBuffers();
|
|
392
|
+
const [first] = await chatTasks(2040);
|
|
393
|
+
await completeTask(first!.id, "Deferred.", { deferredAt: new Date().toISOString() });
|
|
394
|
+
const schedule = await createScheduledTask({
|
|
395
|
+
name: `deferred-${first!.id.slice(0, 8)}-${crypto.randomUUID()}`,
|
|
396
|
+
taskTemplate: "Check the deploy and answer.",
|
|
397
|
+
targetType: "agent-task",
|
|
398
|
+
scheduleType: "one_time",
|
|
399
|
+
nextRunAt: new Date().toISOString(),
|
|
400
|
+
targetAgentId: lead.id,
|
|
401
|
+
createdByAgentId: lead.id,
|
|
402
|
+
taskType: "deferred",
|
|
403
|
+
parentTaskId: first!.id,
|
|
404
|
+
});
|
|
405
|
+
const wakeUp = await createStandaloneScheduleTask(schedule);
|
|
406
|
+
expect(wakeUp.source).toBe("schedule");
|
|
407
|
+
expect(wakeUp.contextKey).toBe(chatSdkContextKey("telegram:2040"));
|
|
408
|
+
|
|
409
|
+
// In progress: a plain message steers the wake-up.
|
|
410
|
+
await startTask(wakeUp.id);
|
|
411
|
+
await postTelegramUpdate(telegramUpdate(dm(2040), "use the staging cluster"));
|
|
412
|
+
await flushChatBuffers();
|
|
413
|
+
expect(await chatTasks(2040)).toHaveLength(1);
|
|
414
|
+
expect(await steeringRows(wakeUp.id)).toEqual([
|
|
415
|
+
{ body: "use the staging cluster", source: "chat" },
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
// Pending: a new message becomes a follow-up that waits for it.
|
|
419
|
+
await getDbClient().run("UPDATE agent_tasks SET status = 'pending' WHERE id = ?", [wakeUp.id]);
|
|
420
|
+
await postTelegramUpdate(telegramUpdate(dm(2040), "and the canary"));
|
|
421
|
+
await flushChatBuffers();
|
|
422
|
+
const tasks = await chatTasks(2040);
|
|
423
|
+
expect(tasks).toHaveLength(2);
|
|
424
|
+
expect((await getTaskById(tasks[1]!.id))?.dependsOn).toEqual([wakeUp.id]);
|
|
425
|
+
|
|
426
|
+
// /cancel stops the follow-up and the wake-up.
|
|
427
|
+
const cancel = telegramUpdate(dm(2040), "/cancel", {
|
|
428
|
+
entities: [{ type: "bot_command", offset: 0, length: "/cancel".length }],
|
|
429
|
+
});
|
|
430
|
+
await postTelegramUpdate(cancel);
|
|
431
|
+
expect((await getTaskById(wakeUp.id))?.status).toBe("cancelled");
|
|
432
|
+
expect((await getTaskById(tasks[1]!.id))?.status).toBe("cancelled");
|
|
433
|
+
});
|
|
434
|
+
|
|
285
435
|
test("a document attachment becomes a task attachment and a [File: ...] line", async () => {
|
|
286
436
|
const bytes = new TextEncoder().encode("meeting notes\n");
|
|
287
437
|
harness.mock.addFile("documents/notes.txt", bytes);
|
|
@@ -451,7 +601,11 @@ describe("Chat SDK requester and group addressing", () => {
|
|
|
451
601
|
const deferred = await getTaskById(task!.id);
|
|
452
602
|
expect(deferred?.output).toMatch(new RegExp(`at ${madrid}$`));
|
|
453
603
|
expect(deferred?.output).not.toContain("UTC");
|
|
454
|
-
|
|
604
|
+
// The relay posts the placeholder text after the task tag (here the trigger
|
|
605
|
+
// drops it: a deferral posts nothing).
|
|
606
|
+
expect(formatChatRelayText(deferred!)).toBe(
|
|
607
|
+
`${formatChatTaskTag(deferred!)}\n\n${deferred!.output!}`,
|
|
608
|
+
);
|
|
455
609
|
});
|
|
456
610
|
|
|
457
611
|
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
|
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
closeDb,
|
|
6
6
|
completeTask,
|
|
7
7
|
createAgent,
|
|
8
|
+
createScheduledTask,
|
|
8
9
|
createTaskExtended,
|
|
9
10
|
failTask,
|
|
10
11
|
getDbClient,
|
|
@@ -19,13 +20,16 @@ import { cleanExtensionTmpRoot, loadExtension } from "../extensions/loader";
|
|
|
19
20
|
import { initExtensionPostBridge, teardownExtensionPostBridge } from "../extensions/post-bridge";
|
|
20
21
|
import { splitChatMessage } from "../integrations/chat-sdk/limits";
|
|
21
22
|
import {
|
|
23
|
+
formatChatRelayMessages,
|
|
22
24
|
formatChatRelayText,
|
|
23
25
|
processPendingChatRelays,
|
|
24
26
|
RELAY_GIVE_UP_AFTER_MS,
|
|
25
27
|
stopChatRelayWatcher,
|
|
28
|
+
TELEGRAM_FOLD_CHARS,
|
|
26
29
|
} from "../integrations/chat-sdk/relay";
|
|
30
|
+
import { createStandaloneScheduleTask } from "../scheduler/schedule-task";
|
|
27
31
|
import { chatSdkContextKey } from "../tasks/context-key";
|
|
28
|
-
import { createResumeFollowUp } from "../tasks/worker-follow-up";
|
|
32
|
+
import { createResumeFollowUp, createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
|
|
29
33
|
import type { Agent, AgentTask } from "../types";
|
|
30
34
|
import { startTelegramHarness, type TelegramHarness } from "./chat-sdk-harness";
|
|
31
35
|
import { loadBundleFixture } from "./fixtures/extensions/load";
|
|
@@ -82,13 +86,19 @@ function sentText(body: Record<string, unknown>): string {
|
|
|
82
86
|
|
|
83
87
|
async function createChatTask(
|
|
84
88
|
chatId: number,
|
|
85
|
-
options: { source?: "chat" | "system"; taskType?: string } = {},
|
|
89
|
+
options: { source?: "chat" | "system"; taskType?: string; askMessageId?: number } = {},
|
|
86
90
|
): Promise<AgentTask> {
|
|
87
91
|
return createTaskExtended(`chat task for ${chatId}`, {
|
|
88
92
|
agentId: lead.id,
|
|
89
93
|
source: options.source ?? "chat",
|
|
90
94
|
taskType: options.taskType,
|
|
91
95
|
contextKey: chatSdkContextKey(`telegram:${chatId}`),
|
|
96
|
+
...(options.askMessageId !== undefined
|
|
97
|
+
? {
|
|
98
|
+
mentionMessageId: `${chatId}:${options.askMessageId}`,
|
|
99
|
+
mentionChannelId: `telegram:${chatId}`,
|
|
100
|
+
}
|
|
101
|
+
: {}),
|
|
92
102
|
});
|
|
93
103
|
}
|
|
94
104
|
|
|
@@ -122,14 +132,45 @@ beforeEach(() => {
|
|
|
122
132
|
});
|
|
123
133
|
|
|
124
134
|
describe("chat relay text", () => {
|
|
125
|
-
test("status
|
|
126
|
-
const base = {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
test("task tag first, status line for failed and cancelled, default for an empty completion", () => {
|
|
136
|
+
const base = {
|
|
137
|
+
id: "a3f2b1c9-0000-4000-8000-000000000000",
|
|
138
|
+
output: undefined,
|
|
139
|
+
failureReason: undefined,
|
|
140
|
+
} as unknown as AgentTask;
|
|
141
|
+
const tag = "#a3f2b1c9 · Coder";
|
|
142
|
+
expect(formatChatRelayText({ ...base, status: "completed" }, "Coder")).toBe(
|
|
143
|
+
`${tag}\n\nTask completed.`,
|
|
144
|
+
);
|
|
145
|
+
expect(formatChatRelayText({ ...base, status: "completed", output: "done" }, "Coder")).toBe(
|
|
146
|
+
`${tag}\n\ndone`,
|
|
147
|
+
);
|
|
148
|
+
expect(formatChatRelayText({ ...base, status: "failed", failureReason: "boom" }, "Coder")).toBe(
|
|
149
|
+
`${tag}\n**Task failed.**\n\nboom`,
|
|
150
|
+
);
|
|
151
|
+
expect(formatChatRelayText({ ...base, status: "cancelled" })).toBe(
|
|
152
|
+
"#a3f2b1c9\n**Task cancelled.**",
|
|
131
153
|
);
|
|
132
|
-
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a long Telegram answer folds into <details>; other adapters and short answers do not", () => {
|
|
157
|
+
const base = { id: "a3f2b1c9-0000-4000-8000-000000000000", status: "completed" };
|
|
158
|
+
const long = `The short answer is yes.\n\n${"detail line\n".repeat(200)}`.trim();
|
|
159
|
+
const task = { ...base, output: long } as unknown as AgentTask;
|
|
160
|
+
expect(long.length).toBeGreaterThan(TELEGRAM_FOLD_CHARS);
|
|
161
|
+
|
|
162
|
+
const [message, ...more] = formatChatRelayMessages(task, "telegram", "Coder");
|
|
163
|
+
expect(more).toHaveLength(0);
|
|
164
|
+
// Tag and first paragraph stay visible; the rest is collapsed.
|
|
165
|
+
expect(message).toStartWith("#a3f2b1c9 · Coder\n\nThe short answer is yes.\n\n<details>");
|
|
166
|
+
expect(message).toContain("<summary>Rest of the answer</summary>");
|
|
167
|
+
expect(message).toEndWith("</details>");
|
|
168
|
+
|
|
169
|
+
expect(formatChatRelayMessages(task, "slack", "Coder")).toEqual([
|
|
170
|
+
formatChatRelayText(task, "Coder"),
|
|
171
|
+
]);
|
|
172
|
+
const short = { ...base, output: "fine" } as unknown as AgentTask;
|
|
173
|
+
expect(formatChatRelayMessages(short, "telegram")).toEqual(["#a3f2b1c9\n\nfine"]);
|
|
133
174
|
});
|
|
134
175
|
|
|
135
176
|
test("splitChatMessage keeps every chunk within the limit and loses no text", () => {
|
|
@@ -289,7 +330,7 @@ describe("chat relay watcher", () => {
|
|
|
289
330
|
adapter: "telegram",
|
|
290
331
|
threadId: `telegram:${chatId}`,
|
|
291
332
|
direction: "outbound",
|
|
292
|
-
text:
|
|
333
|
+
text: `#${task.id.slice(0, 8)} · ChatRelayLead\n\nobserved by an extension`,
|
|
293
334
|
taskId: task.id,
|
|
294
335
|
});
|
|
295
336
|
});
|
|
@@ -408,6 +449,316 @@ describe("chat relay watcher", () => {
|
|
|
408
449
|
expect((await obligation(resume.id))?.delivered_at).not.toBeNull();
|
|
409
450
|
});
|
|
410
451
|
|
|
452
|
+
test("the answer replies to the ask and starts with the task tag", async () => {
|
|
453
|
+
const chatId = nextChatId;
|
|
454
|
+
const task = await createChatTask(chatId, { askMessageId: 41 });
|
|
455
|
+
await completeTask(task.id, "Here is your answer.");
|
|
456
|
+
await processPendingChatRelays();
|
|
457
|
+
|
|
458
|
+
const [send, ...rest] = sendsTo(chatId);
|
|
459
|
+
expect(rest).toHaveLength(0);
|
|
460
|
+
expect(send?.reply_parameters).toEqual({ message_id: 41, allow_sending_without_reply: true });
|
|
461
|
+
// The line-leading tag is escaped so Telegram does not render it as a heading.
|
|
462
|
+
expect(sentText(send ?? {})).toStartWith(`\\#${task.id.slice(0, 8)} · ChatRelayLead`);
|
|
463
|
+
expect(sentText(send ?? {})).toContain("Here is your answer.");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("the answer turns the ask's reaction into 👍, or 👎 for a failed task", async () => {
|
|
467
|
+
const reactionsTo = (chatId: number) =>
|
|
468
|
+
harness.mock.calls
|
|
469
|
+
.filter((call) => call.method === "setMessageReaction")
|
|
470
|
+
.map((call) => call.body as Record<string, unknown>)
|
|
471
|
+
.filter((body) => String(body.chat_id) === String(chatId));
|
|
472
|
+
const doneChat = nextChatId;
|
|
473
|
+
const done = await createChatTask(doneChat, { askMessageId: 51 });
|
|
474
|
+
await completeTask(done.id, "Done.");
|
|
475
|
+
const failedChat = nextChatId + 2_000;
|
|
476
|
+
const failed = await createChatTask(failedChat, { askMessageId: 52 });
|
|
477
|
+
await failTask(failed.id, "the build broke");
|
|
478
|
+
await processPendingChatRelays();
|
|
479
|
+
|
|
480
|
+
expect(reactionsTo(doneChat)).toEqual([
|
|
481
|
+
expect.objectContaining({ message_id: 51, reaction: [{ type: "emoji", emoji: "👍" }] }),
|
|
482
|
+
]);
|
|
483
|
+
expect(reactionsTo(failedChat)).toEqual([
|
|
484
|
+
expect.objectContaining({ message_id: 52, reaction: [{ type: "emoji", emoji: "👎" }] }),
|
|
485
|
+
]);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("a REACTION_INVALID answer never fails the delivery", async () => {
|
|
489
|
+
const chatId = nextChatId;
|
|
490
|
+
const task = await createChatTask(chatId, { askMessageId: 61 });
|
|
491
|
+
await completeTask(task.id, "Still delivered.");
|
|
492
|
+
harness.mock.failReactions(1);
|
|
493
|
+
try {
|
|
494
|
+
await processPendingChatRelays();
|
|
495
|
+
} finally {
|
|
496
|
+
harness.mock.failReactions(0);
|
|
497
|
+
}
|
|
498
|
+
expect(sentText(sendsTo(chatId)[0] ?? {})).toContain("Still delivered.");
|
|
499
|
+
const row = await obligation(task.id);
|
|
500
|
+
expect(row?.delivered_at).not.toBeNull();
|
|
501
|
+
expect(row?.last_error).toBeNull();
|
|
502
|
+
expect(
|
|
503
|
+
harness.mock.calls.some(
|
|
504
|
+
(call) =>
|
|
505
|
+
call.method === "setMessageReaction" &&
|
|
506
|
+
String((call.body as Record<string, unknown>).chat_id) === String(chatId),
|
|
507
|
+
),
|
|
508
|
+
).toBe(true);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("a chunked answer anchors only its first message", async () => {
|
|
512
|
+
const chatId = nextChatId;
|
|
513
|
+
const task = await createChatTask(chatId, { askMessageId: 42 });
|
|
514
|
+
const output = Array.from({ length: 1000 }, (_, i) => `line${String(i).padStart(4, "0")}`).join(
|
|
515
|
+
"\n",
|
|
516
|
+
);
|
|
517
|
+
await completeTask(task.id, output);
|
|
518
|
+
await processPendingChatRelays();
|
|
519
|
+
|
|
520
|
+
const sends = sendsTo(chatId);
|
|
521
|
+
expect(sends.length).toBeGreaterThan(1);
|
|
522
|
+
expect(sends[0]?.reply_parameters).toEqual({
|
|
523
|
+
message_id: 42,
|
|
524
|
+
allow_sending_without_reply: true,
|
|
525
|
+
});
|
|
526
|
+
for (const later of sends.slice(1)) expect(later.reply_parameters).toBeUndefined();
|
|
527
|
+
// Every message carries a whole collapsed block.
|
|
528
|
+
for (const body of sends) {
|
|
529
|
+
expect(sentText(body)).toContain("<details>");
|
|
530
|
+
expect(sentText(body)).toEndWith("</details>");
|
|
531
|
+
expect(sentText(body).length).toBeLessThanOrEqual(4096);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("a deferred task posts nothing; its wake-up answers the original ask", async () => {
|
|
536
|
+
const chatId = nextChatId;
|
|
537
|
+
const deferred = await createChatTask(chatId, { askMessageId: 43 });
|
|
538
|
+
await completeTask(deferred.id, "Deferred until 18:00.", {
|
|
539
|
+
addTags: ["deferred"],
|
|
540
|
+
deferredAt: new Date().toISOString(),
|
|
541
|
+
});
|
|
542
|
+
expect(await obligation(deferred.id)).toBeNull();
|
|
543
|
+
|
|
544
|
+
// What defer-task books; the scheduler fires it later.
|
|
545
|
+
const schedule = await createScheduledTask({
|
|
546
|
+
name: `deferred-${deferred.id.slice(0, 8)}-${crypto.randomUUID()}`,
|
|
547
|
+
taskTemplate: "Check the deploy and answer.",
|
|
548
|
+
targetType: "agent-task",
|
|
549
|
+
scheduleType: "one_time",
|
|
550
|
+
nextRunAt: new Date().toISOString(),
|
|
551
|
+
targetAgentId: lead.id,
|
|
552
|
+
createdByAgentId: lead.id,
|
|
553
|
+
taskType: "deferred",
|
|
554
|
+
tags: ["deferred"],
|
|
555
|
+
parentTaskId: deferred.id,
|
|
556
|
+
});
|
|
557
|
+
const wakeUp = await createStandaloneScheduleTask(schedule);
|
|
558
|
+
expect(wakeUp.contextKey).toBe(deferred.contextKey);
|
|
559
|
+
expect(wakeUp.mentionMessageId).toBe(`${chatId}:43`);
|
|
560
|
+
await completeTask(wakeUp.id, "The deploy is live.");
|
|
561
|
+
|
|
562
|
+
await processPendingChatRelays();
|
|
563
|
+
const sends = sendsTo(chatId);
|
|
564
|
+
expect(sends).toHaveLength(1);
|
|
565
|
+
expect(sentText(sends[0] ?? {})).toContain("The deploy is live.");
|
|
566
|
+
expect(sentText(sends[0] ?? {})).not.toContain("Deferred until");
|
|
567
|
+
expect(sends[0]?.reply_parameters).toEqual({
|
|
568
|
+
message_id: 43,
|
|
569
|
+
allow_sending_without_reply: true,
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("a wake-up of a task outside chat keeps its schedule contextKey", async () => {
|
|
574
|
+
const parent = await createTaskExtended("not a chat task", { agentId: lead.id });
|
|
575
|
+
const schedule = await createScheduledTask({
|
|
576
|
+
name: `deferred-${parent.id.slice(0, 8)}-${crypto.randomUUID()}`,
|
|
577
|
+
taskTemplate: "wake up",
|
|
578
|
+
targetType: "agent-task",
|
|
579
|
+
scheduleType: "one_time",
|
|
580
|
+
nextRunAt: new Date().toISOString(),
|
|
581
|
+
targetAgentId: lead.id,
|
|
582
|
+
createdByAgentId: lead.id,
|
|
583
|
+
taskType: "deferred",
|
|
584
|
+
parentTaskId: parent.id,
|
|
585
|
+
});
|
|
586
|
+
const wakeUp = await createStandaloneScheduleTask(schedule);
|
|
587
|
+
expect(wakeUp.contextKey).toBe(`task:schedule:${schedule.id}`);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
describe("a delegated worker's deferral", () => {
|
|
591
|
+
let worker: Agent;
|
|
592
|
+
let otherWorker: Agent;
|
|
593
|
+
|
|
594
|
+
beforeAll(async () => {
|
|
595
|
+
worker = await createAgent({
|
|
596
|
+
id: crypto.randomUUID(),
|
|
597
|
+
name: "ChatRelayDeferringWorker",
|
|
598
|
+
isLead: false,
|
|
599
|
+
status: "idle",
|
|
600
|
+
maxTasks: 5,
|
|
601
|
+
});
|
|
602
|
+
otherWorker = await createAgent({
|
|
603
|
+
id: crypto.randomUUID(),
|
|
604
|
+
name: "ChatRelayRetargetWorker",
|
|
605
|
+
isLead: false,
|
|
606
|
+
status: "idle",
|
|
607
|
+
maxTasks: 5,
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
/** A Lead chat ask (anchored on message 43) delegated to `worker`. */
|
|
612
|
+
async function delegate(
|
|
613
|
+
chatId: number,
|
|
614
|
+
): Promise<{ leadTask: AgentTask; delegated: AgentTask }> {
|
|
615
|
+
const leadTask = await createChatTask(chatId, { askMessageId: 43 });
|
|
616
|
+
const delegated = await createTaskExtended("worker part", {
|
|
617
|
+
agentId: worker.id,
|
|
618
|
+
creatorAgentId: lead.id,
|
|
619
|
+
source: "mcp",
|
|
620
|
+
parentTaskId: leadTask.id,
|
|
621
|
+
});
|
|
622
|
+
expect(delegated.contextKey).toBe(leadTask.contextKey);
|
|
623
|
+
return { leadTask, delegated };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** What defer-task books for `task`, fired by the scheduler. */
|
|
627
|
+
async function deferAndWake(
|
|
628
|
+
task: AgentTask,
|
|
629
|
+
options: { targetAgentId?: string } = {},
|
|
630
|
+
): Promise<AgentTask> {
|
|
631
|
+
await completeTask(task.id, "Checking back later.", {
|
|
632
|
+
deferredAt: new Date().toISOString(),
|
|
633
|
+
});
|
|
634
|
+
const schedule = await createScheduledTask({
|
|
635
|
+
name: `deferred-${task.id.slice(0, 8)}-${crypto.randomUUID()}`,
|
|
636
|
+
taskTemplate: "resume the worker part",
|
|
637
|
+
targetType: "agent-task",
|
|
638
|
+
scheduleType: "one_time",
|
|
639
|
+
nextRunAt: new Date().toISOString(),
|
|
640
|
+
targetAgentId: options.targetAgentId ?? task.agentId ?? undefined,
|
|
641
|
+
createdByAgentId: task.agentId ?? undefined,
|
|
642
|
+
taskType: "deferred",
|
|
643
|
+
parentTaskId: task.id,
|
|
644
|
+
});
|
|
645
|
+
const wakeUp = await createStandaloneScheduleTask(schedule);
|
|
646
|
+
// The worker reports through the Lead, as its delegated task did.
|
|
647
|
+
expect(wakeUp.contextKey).toBe(`task:schedule:${schedule.id}`);
|
|
648
|
+
return wakeUp;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Completes the worker's `task` with raw output, then runs its Lead
|
|
653
|
+
* follow-up to a reviewed answer. Returns what reached the chat.
|
|
654
|
+
*/
|
|
655
|
+
async function reportThroughLead(
|
|
656
|
+
task: AgentTask,
|
|
657
|
+
chatId: number,
|
|
658
|
+
leadTask: AgentTask,
|
|
659
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
660
|
+
const completed = await completeTask(task.id, "RAW PRIVATE WORKER RESULT");
|
|
661
|
+
expect(completed).not.toBeNull();
|
|
662
|
+
expect(await obligation(task.id)).toBeNull();
|
|
663
|
+
const followUp = await createWorkerTaskFollowUp({
|
|
664
|
+
task: completed!,
|
|
665
|
+
status: "completed",
|
|
666
|
+
output: completed!.output,
|
|
667
|
+
});
|
|
668
|
+
await processPendingChatRelays();
|
|
669
|
+
expect(sendsTo(chatId)).toHaveLength(0);
|
|
670
|
+
|
|
671
|
+
expect(followUp?.agentId).toBe(lead.id);
|
|
672
|
+
expect(followUp?.contextKey).toBe(leadTask.contextKey);
|
|
673
|
+
expect(followUp?.mentionMessageId).toBe(`${chatId}:43`);
|
|
674
|
+
await completeTask(followUp!.id, "REVIEWED LEAD ANSWER");
|
|
675
|
+
await processPendingChatRelays();
|
|
676
|
+
return sendsTo(chatId);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function expectOneReviewedAnswer(sends: Array<Record<string, unknown>>): void {
|
|
680
|
+
expect(sends).toHaveLength(1);
|
|
681
|
+
expect(sentText(sends[0] ?? {})).toContain("REVIEWED LEAD ANSWER");
|
|
682
|
+
expect(sentText(sends[0] ?? {})).not.toContain("RAW PRIVATE WORKER RESULT");
|
|
683
|
+
expect(sends[0]?.reply_parameters).toEqual({
|
|
684
|
+
message_id: 43,
|
|
685
|
+
allow_sending_without_reply: true,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
test("control: an undeferred worker reports one reviewed answer", async () => {
|
|
690
|
+
const chatId = nextChatId;
|
|
691
|
+
const { leadTask, delegated } = await delegate(chatId);
|
|
692
|
+
expectOneReviewedAnswer(await reportThroughLead(delegated, chatId, leadTask));
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("wakes up outside the chat, stays silent and reports through the Lead", async () => {
|
|
696
|
+
const chatId = nextChatId;
|
|
697
|
+
const { leadTask, delegated } = await delegate(chatId);
|
|
698
|
+
const wakeUp = await deferAndWake(delegated);
|
|
699
|
+
expectOneReviewedAnswer(await reportThroughLead(wakeUp, chatId, leadTask));
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("a repeated deferral still reports through the Lead", async () => {
|
|
703
|
+
const chatId = nextChatId;
|
|
704
|
+
const { leadTask, delegated } = await delegate(chatId);
|
|
705
|
+
const firstWake = await deferAndWake(delegated);
|
|
706
|
+
const secondWake = await deferAndWake(firstWake);
|
|
707
|
+
expect(await obligation(firstWake.id)).toBeNull();
|
|
708
|
+
expectOneReviewedAnswer(await reportThroughLead(secondWake, chatId, leadTask));
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("a resume of the wake-up still reports through the Lead", async () => {
|
|
712
|
+
const chatId = nextChatId;
|
|
713
|
+
const { leadTask, delegated } = await delegate(chatId);
|
|
714
|
+
const wakeUp = await deferAndWake(delegated);
|
|
715
|
+
await startTask(wakeUp.id);
|
|
716
|
+
await supersedeTask(wakeUp.id, { reason: "graceful_shutdown", resumeTaskId: null });
|
|
717
|
+
const result = await createResumeFollowUp({
|
|
718
|
+
parentId: wakeUp.id,
|
|
719
|
+
reason: "graceful_shutdown",
|
|
720
|
+
});
|
|
721
|
+
expect(result.kind).toBe("created");
|
|
722
|
+
if (result.kind !== "created") return;
|
|
723
|
+
expect(result.task.agentId).toBe(worker.id);
|
|
724
|
+
expect(result.task.contextKey).toBe(wakeUp.contextKey);
|
|
725
|
+
expectOneReviewedAnswer(await reportThroughLead(result.task, chatId, leadTask));
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("a wake-up re-targeted to another worker reports through the Lead", async () => {
|
|
729
|
+
const chatId = nextChatId;
|
|
730
|
+
const { leadTask, delegated } = await delegate(chatId);
|
|
731
|
+
const wakeUp = await deferAndWake(delegated, { targetAgentId: otherWorker.id });
|
|
732
|
+
expect(wakeUp.agentId).toBe(otherWorker.id);
|
|
733
|
+
expectOneReviewedAnswer(await reportThroughLead(wakeUp, chatId, leadTask));
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("a deferred wake-up that runs as another agent than its parent is not relayed", async () => {
|
|
738
|
+
const other = await createAgent({
|
|
739
|
+
id: crypto.randomUUID(),
|
|
740
|
+
name: "ChatRelayOtherAgent",
|
|
741
|
+
isLead: false,
|
|
742
|
+
status: "idle",
|
|
743
|
+
});
|
|
744
|
+
const chatId = nextChatId;
|
|
745
|
+
const parent = await createChatTask(chatId);
|
|
746
|
+
await completeTask(parent.id, "Deferred.", { deferredAt: new Date().toISOString() });
|
|
747
|
+
// Bypasses the scheduler's check: the triggers hold the same line.
|
|
748
|
+
const wakeUp = await createTaskExtended("not the deferring agent", {
|
|
749
|
+
agentId: other.id,
|
|
750
|
+
source: "schedule",
|
|
751
|
+
taskType: "deferred",
|
|
752
|
+
parentTaskId: parent.id,
|
|
753
|
+
contextKey: parent.contextKey,
|
|
754
|
+
});
|
|
755
|
+
await completeTask(wakeUp.id, "OTHER AGENT OUTPUT");
|
|
756
|
+
|
|
757
|
+
expect(await obligation(wakeUp.id)).toBeNull();
|
|
758
|
+
await processPendingChatRelays();
|
|
759
|
+
expect(sendsTo(chatId)).toHaveLength(0);
|
|
760
|
+
});
|
|
761
|
+
|
|
411
762
|
test("delegated worker tasks and their resumes stay silent", async () => {
|
|
412
763
|
const worker = await createAgent({
|
|
413
764
|
id: crypto.randomUUID(),
|