@desplega.ai/agent-swarm 2.5.1 → 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-7g6bpwbk.js → acp-adapter-csh0zz3b.js} +1 -1
- package/dist/{actions-w8xd014k.js → actions-6xx079d1.js} +17 -17
- package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-8n6f0hz8.js} +2 -2
- package/dist/{app-nndcvftq.js → app-ft6r6v23.js} +3 -3
- package/dist/{assistant-hjmqyscx.js → assistant-vt2cyfzd.js} +18 -18
- package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-r3e808ky.js} +1 -1
- package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-tgrfy27t.js} +1 -1
- package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-masdwxkm.js} +2 -2
- package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-0n82qc7q.js} +4 -4
- package/dist/{claude-adapter-2f8b85df.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-05kat8rv.js → claude-sdk-session-0zrdzyx2.js} +6905 -6855
- package/dist/{cli-mz2ygeyq.js → cli-0vgy5wfw.js} +2 -2
- package/dist/{cli-eym3jg7w.js → cli-14v1sxxy.js} +6 -6
- package/dist/{cli-e9btqw4y.js → cli-15xd0j7w.js} +18 -13
- package/dist/{cli-v2g93k4v.js → cli-3e9vs93g.js} +4 -4
- package/dist/{cli-1dp1zj04.js → cli-4qkg9968.js} +3 -3
- package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
- package/dist/{cli-rngde2jv.js → cli-6y43v79h.js} +1 -1
- package/dist/{cli-z63a269p.js → cli-7ccf7akh.js} +1 -1
- package/dist/{cli-z2dv25mf.js → cli-86zpfje5.js} +116 -5
- package/dist/{cli-fes51ya2.js → cli-aaqnqpyw.js} +2 -2
- package/dist/{cli-qfm6my86.js → cli-b1tv22wr.js} +1 -1
- package/dist/{cli-xc65x52q.js → cli-b63d08rs.js} +1 -1
- package/dist/{cli-gwcf52gz.js → cli-b6tnjdvx.js} +11 -7
- package/dist/{cli-9mm5ade4.js → cli-bpbcpd8b.js} +1 -1
- package/dist/{cli-qfjm4gvt.js → cli-cf63dnxj.js} +2 -2
- package/dist/{cli-q9pzwxvk.js → cli-csh8fnbg.js} +82 -16
- package/dist/{cli-0jy85zz4.js → cli-cyfgxnvb.js} +7 -7
- package/dist/{cli-y3zyaee5.js → cli-d172svcw.js} +5 -5
- package/dist/{cli-mq7kg1b1.js → cli-e91vshb1.js} +41 -6
- package/dist/{cli-cah5detr.js → cli-edtm741g.js} +5 -5
- package/dist/{cli-ajqhxtnz.js → cli-f3pfn4x3.js} +1 -1
- package/dist/{cli-hadv0725.js → cli-f55b6ky0.js} +1 -1
- package/dist/{cli-r9fy0ddc.js → cli-fgn7yg5r.js} +1 -1
- package/dist/{cli-kcsd1ez8.js → cli-h5d22pfs.js} +249 -68
- package/dist/{cli-2wba1ac9.js → cli-ha22h0eg.js} +20 -20
- package/dist/{cli-pyy4c9ch.js → cli-j3dwd4kz.js} +173 -134
- package/dist/{cli-j1eghqfm.js → cli-jmcjbjkt.js} +1 -1
- package/dist/{cli-22rmcjkg.js → cli-kcnwn85g.js} +4 -3
- package/dist/{cli-rh5smt8t.js → cli-mntxtkhn.js} +12 -2
- package/dist/{cli-2jg61wab.js → cli-n2918sed.js} +9 -9
- package/dist/{cli-3ee5z0q0.js → cli-qrjv7t18.js} +1 -1
- package/dist/{cli-sn1vbtg2.js → cli-vaxw3bg0.js} +3 -3
- package/dist/{cli-cwyafhw1.js → cli-wadzapwb.js} +1 -1
- package/dist/{cli-jpxsrxcb.js → cli-xw9q173h.js} +1 -1
- package/dist/{cli-znvq0k3t.js → cli-xzge46sj.js} +2 -2
- package/dist/{cli-nj12tn2c.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-p4ptwar5.js → codex-adapter-brh2tcxr.js} +1 -1
- package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-0dnj21qs.js} +1 -1
- package/dist/{commands-mbvhbrcy.js → commands-1cnvseqx.js} +2 -2
- package/dist/{controller-gnq1k1mf.js → controller-kc7s9xhn.js} +2 -2
- package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-jzx7t4bn.js} +18 -18
- package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-qpwp5sfg.js} +1 -1
- package/dist/{db-af9y8nkf.js → db-4vq88jkc.js} +3 -3
- package/dist/{db-88wcymxt.js → db-v5ytg5pw.js} +1 -1
- package/dist/{dispatcher-606ydx5v.js → dispatcher-trtcrqae.js} +16 -16
- package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-f20xwn1z.js} +1 -1
- package/dist/{external-channel-y8q5z8z2.js → external-channel-n9bymc2n.js} +2 -2
- package/dist/{grok-adapter-39aha52t.js → grok-adapter-4kpmmnr6.js} +2 -2
- package/dist/{handlers-rk9dqech.js → handlers-ptsraqa8.js} +19 -19
- package/dist/{heartbeat-jfe9zfjr.js → heartbeat-60qtxtjf.js} +19 -19
- package/dist/{hook-ffevzed1.js → hook-ngtvh729.js} +1 -1
- package/dist/{hook-b1mez1m8.js → hook-rbktcstg.js} +1 -1
- package/dist/{http-d5b44f1r.js → http-z8xy72gp.js} +334 -86
- package/dist/{index-nq8hhv28.js → index-06qkmcx6.js} +10 -9
- package/dist/{index-d2qeem97.js → index-1cscynkb.js} +16 -16
- package/dist/{index-5en23s5z.js → index-a9nsavhx.js} +1 -1
- package/dist/{index-4q3vj1yr.js → index-r70q0xjx.js} +16 -16
- package/dist/{index-mj46rk9k.js → index-s193j5fh.js} +19 -19
- package/dist/{keepalive-wsby1btq.js → keepalive-a9srcphj.js} +3 -3
- package/dist/{lead-paa141j6.js → lead-3devneqy.js} +26 -26
- package/dist/{maintenance-vnshcyv9.js → maintenance-0er16h2y.js} +1 -1
- package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-b65jcyw2.js} +2 -2
- package/dist/{onboard-nr4xhpzy.js → onboard-wqywz5py.js} +2 -2
- package/dist/{otel-impl-ytn9wyv9.js → otel-impl-vk32s884.js} +1 -1
- package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-t27anv4z.js} +3 -3
- package/dist/rbac-roles-452665y0.js +26 -0
- package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-bmcgsxjn.js} +12 -2
- package/dist/{render-v2-pyfsg9zk.js → render-v2-0ttsk2j4.js} +3 -3
- package/dist/{resume-phj8xhqr.js → resume-a26kvr41.js} +5 -5
- package/dist/{session-access-jzx2z9h5.js → session-access-dgh9nkwk.js} +4 -4
- package/dist/{setup-rfg0f0sg.js → setup-9vc9qahm.js} +1 -1
- package/dist/{work-objects-44z2yjg8.js → work-objects-vn8nkxwn.js} +19 -19
- package/dist/{worker-zsfqzxhm.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/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/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-ff1ffmec.js +0 -12
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from "@/be/script-connections";
|
|
26
26
|
import { buildScriptCredentialBindingsWithFailures } from "@/be/script-credential-broker";
|
|
27
27
|
import { getScript } from "@/be/scripts/db";
|
|
28
|
-
import {
|
|
28
|
+
import { runScriptWithRunCredential } from "@/be/scripts/run-credential";
|
|
29
29
|
import { telemetry } from "@/telemetry";
|
|
30
30
|
import type { AgentTask, ScheduledTask } from "@/types";
|
|
31
31
|
import { getExecutorRegistry as getWorkflowExecutorRegistry } from "@/workflows";
|
|
@@ -72,7 +72,7 @@ function resolveExecutorRegistry(): ExecutorRegistry | null {
|
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
74
|
* Execute a schedule's `targetType='script'` target directly via the
|
|
75
|
-
* scripts-runtime — no agent/LLM in the loop. Reuses the same `
|
|
75
|
+
* scripts-runtime — no agent/LLM in the loop. Reuses the same `runScriptWithRunCredential()`
|
|
76
76
|
* path as the `swarm-script` workflow executor.
|
|
77
77
|
*/
|
|
78
78
|
async function executeScheduleScript(schedule: ScheduledTask): Promise<void> {
|
|
@@ -98,10 +98,10 @@ async function executeScheduleScript(schedule: ScheduledTask): Promise<void> {
|
|
|
98
98
|
startedAt: new Date().toISOString(),
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
-
let output: Awaited<ReturnType<typeof
|
|
101
|
+
let output: Awaited<ReturnType<typeof runScriptWithRunCredential>>;
|
|
102
102
|
try {
|
|
103
103
|
const credentials = await buildScriptCredentialBindingsWithFailures({ agentId });
|
|
104
|
-
output = await
|
|
104
|
+
output = await runScriptWithRunCredential({
|
|
105
105
|
source: script.source,
|
|
106
106
|
args,
|
|
107
107
|
fsMode: "none",
|
|
@@ -40,6 +40,13 @@ export type RunScriptInput = {
|
|
|
40
40
|
args?: unknown;
|
|
41
41
|
fsMode?: ScriptFsMode;
|
|
42
42
|
agentId: string;
|
|
43
|
+
/**
|
|
44
|
+
* Bearer for the run's `ctx.swarm` SDK calls. Api-side callers pass a
|
|
45
|
+
* per-run token bound to the executing agent (`runScriptWithRunCredential`
|
|
46
|
+
* in `src/be/scripts/run-credential.ts`, which also revokes it). Omitted, the
|
|
47
|
+
* run presents the swarm API key.
|
|
48
|
+
*/
|
|
49
|
+
apiKey?: string;
|
|
43
50
|
/** Per-boot runtime identity of the invoking worker; system context, never script input. */
|
|
44
51
|
runtimeInstanceId?: string;
|
|
45
52
|
signal?: AbortSignal;
|
|
@@ -74,7 +81,7 @@ export function scriptEgressPolicy(env: NodeJS.ProcessEnv = process.env): Egress
|
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
async function buildConfigPayload(input: RunScriptInput): Promise<SwarmConfigPayload> {
|
|
77
|
-
const apiKey = getApiKey();
|
|
84
|
+
const apiKey = input.apiKey ?? getApiKey();
|
|
78
85
|
if (!apiKey) throw new Error("Swarm API key is required to run scripts");
|
|
79
86
|
|
|
80
87
|
return {
|
package/src/slack/ack.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { WebClient } from "@slack/web-api";
|
|
2
2
|
import { getLogsByTaskIdChronological, getSlackTasksInThread, getTaskById } from "../be/db";
|
|
3
|
+
import { terminalReactionEvent } from "../integrations/reaction-events";
|
|
3
4
|
import { recordSlackReactionInvalidName } from "../otel";
|
|
4
5
|
import { type AgentTask, isTerminalTaskStatus } from "../types";
|
|
5
6
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
@@ -143,7 +144,7 @@ export type SlackReactionChoice = { name: string; event?: SlackReactionEvent };
|
|
|
143
144
|
|
|
144
145
|
/** The reaction choice for a task's terminal outcome, honouring operator config. */
|
|
145
146
|
export function terminalReactionChoice(task: AgentTask): SlackReactionChoice {
|
|
146
|
-
const event
|
|
147
|
+
const event = terminalReactionEvent(task.status);
|
|
147
148
|
return { name: reactionName(event), event };
|
|
148
149
|
}
|
|
149
150
|
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
| "steered"
|
|
6
|
-
| "completed"
|
|
7
|
-
| "failed";
|
|
1
|
+
import type { ReactionEvent } from "@/integrations/reaction-events";
|
|
2
|
+
|
|
3
|
+
/** The shared lifecycle events; Slack maps each one to a shortcode below. */
|
|
4
|
+
export type SlackReactionEvent = ReactionEvent;
|
|
8
5
|
|
|
9
6
|
export const SLACK_REACTION_DEFAULTS: Record<SlackReactionEvent, string> = {
|
|
10
7
|
accepted: "eyes",
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "../types";
|
|
34
34
|
import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
35
35
|
import { taskAttachmentDisplayUrl } from "../utils/task-attachment-links";
|
|
36
|
+
import { chatThreadOfContextKey } from "./context-key";
|
|
36
37
|
import { taskModelPin } from "./model-pin";
|
|
37
38
|
import { createTaskWithSiblingAwareness } from "./sibling-awareness";
|
|
38
39
|
// Side-effect import: registers task lifecycle templates in the in-memory registry.
|
|
@@ -386,6 +387,7 @@ export async function createWorkerTaskFollowUp(args: {
|
|
|
386
387
|
taskType: "follow-up",
|
|
387
388
|
priority: changes.priority,
|
|
388
389
|
parentTaskId: task.id,
|
|
390
|
+
contextKey: await deferredWorkChatContextKey(task),
|
|
389
391
|
slackChannelId: task.slackChannelId,
|
|
390
392
|
slackThreadTs: task.slackThreadTs,
|
|
391
393
|
slackUserId: task.slackUserId,
|
|
@@ -398,6 +400,34 @@ export async function createWorkerTaskFollowUp(args: {
|
|
|
398
400
|
);
|
|
399
401
|
}
|
|
400
402
|
|
|
403
|
+
/** Hop cap for `deferredWorkChatContextKey`; a defer/resume chain is short. */
|
|
404
|
+
const MAX_CONTINUATION_HOPS = 32;
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The chat thread a worker's deferred work answers in, for its Lead follow-up.
|
|
408
|
+
* A worker's defer-task wake-up (and any resume of it) runs on its schedule's
|
|
409
|
+
* contextKey, so the worker stays out of the chat (`scheduleTaskContextKey`).
|
|
410
|
+
* The Lead follow-up still answers the original ask: walk back through the
|
|
411
|
+
* wake-up and resume chain to the deferred task and report on its chat thread.
|
|
412
|
+
* Undefined when the task already has a chat key or the chain leads to none,
|
|
413
|
+
* so the follow-up inherits the task's own key as before.
|
|
414
|
+
*/
|
|
415
|
+
async function deferredWorkChatContextKey(task: AgentTask): Promise<string | undefined> {
|
|
416
|
+
if (chatThreadOfContextKey(task.contextKey)) return undefined;
|
|
417
|
+
const seen = new Set<string>();
|
|
418
|
+
let current: AgentTask | null = task;
|
|
419
|
+
while (current && !seen.has(current.id) && seen.size < MAX_CONTINUATION_HOPS) {
|
|
420
|
+
if (chatThreadOfContextKey(current.contextKey)) return current.contextKey;
|
|
421
|
+
const continuation =
|
|
422
|
+
(current.source === "schedule" && current.taskType === "deferred") ||
|
|
423
|
+
(current.source === "system" && current.taskType === "resume");
|
|
424
|
+
if (!continuation || !current.parentTaskId) return undefined;
|
|
425
|
+
seen.add(current.id);
|
|
426
|
+
current = await getTaskById(current.parentTaskId);
|
|
427
|
+
}
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
401
431
|
/** Result of `createResumeFollowUp`. */
|
|
402
432
|
export type CreateResumeFollowUpResult =
|
|
403
433
|
| { kind: "created"; task: AgentTask }
|
|
@@ -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,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
|
});
|