@desplega.ai/agent-swarm 1.161.0 → 1.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/{acp-adapter-xx489vvb.js → acp-adapter-74m73ae9.js} +1 -1
- package/dist/{actions-ykjkn0m9.js → actions-4sr14gnt.js} +34 -31
- package/dist/{anthropic-messages-5qx80j9k.js → anthropic-messages-8xxrzky9.js} +22 -26
- package/dist/{app-0hdqa4k1.js → app-pt68fbwp.js} +13 -11
- package/dist/{artifact-3v5t9jtg.js → artifact-fey7dvj9.js} +3 -2
- package/dist/{assistant-am2g5g8a.js → assistant-cv3jytks.js} +37 -34
- package/dist/{azure-openai-responses-fszbkp0e.js → azure-openai-responses-f5cqsyj8.js} +15 -10
- package/dist/{boot-reembed-xy9t7avg.js → boot-reembed-4ht4gqth.js} +13 -11
- package/dist/{boot-reembed-gt5gjkt4.js → boot-reembed-nxpcvpj9.js} +12 -10
- package/dist/{boot-scrub-logs-9x0985y5.js → boot-scrub-logs-k93kyw8k.js} +9 -7
- package/dist/{boot-seeds-trv0d4vg.js → boot-seeds-1skcrs0e.js} +13 -10
- package/dist/{claude-adapter-jb6e1e54.js → claude-adapter-r5g0tzge.js} +23 -20
- package/dist/{claude-managed-setup-d1q9hv2b.js → claude-managed-setup-pkyrgshb.js} +3 -2
- package/dist/{claude-sdk-session-zga700s2.js → claude-sdk-session-jxyxnrwd.js} +5565 -5512
- package/dist/{cli-we0q7t0e.js → cli-2zy95vhm.js} +137 -64
- package/dist/{cli-35pj2yva.js → cli-4cwxn7gp.js} +38 -11
- package/dist/{cli-ad4qxm6j.js → cli-4dcpaefp.js} +2 -2
- package/dist/{cli-82c1pacn.js → cli-4x0csdjq.js} +71 -37
- package/dist/{cli-sqm3r5ps.js → cli-5bcd7sfy.js} +14 -18
- package/dist/{cli-d56tgd45.js → cli-5k6k8ta3.js} +2 -2
- package/dist/{cli-1er0rhz8.js → cli-5xhpb3kv.js} +15 -15
- package/dist/{cli-yrg7c73k.js → cli-7af8yhav.js} +10 -183
- package/dist/{cli-r6fsxc76.js → cli-7tre70zp.js} +513 -961
- package/dist/{cli-bh7jed3k.js → cli-8ev9xqnx.js} +34 -18
- package/dist/cli-bsky8g7w.js +449 -0
- package/dist/{cli-hn04t9db.js → cli-cebmgwsc.js} +14 -10
- package/dist/{cli-cfkrztza.js → cli-cvatsam7.js} +2 -2
- package/dist/{cli-2658f6at.js → cli-cvk6yzw5.js} +6 -8
- package/dist/{cli-ptr8h7kp.js → cli-eg0b3p0k.js} +5 -218
- package/dist/{cli-zb6xbk61.js → cli-g739zqzt.js} +4 -4
- package/dist/{cli-dv6cdjs2.js → cli-gd4favx9.js} +28 -7
- package/dist/{cli-5p3x4zna.js → cli-gpqpmns7.js} +2 -2
- package/dist/{cli-5nbmpy8w.js → cli-hb5c2x5y.js} +1 -1
- package/dist/{cli-jpf7e1zq.js → cli-hmsw1gpy.js} +3 -3
- package/dist/{cli-1cz0c18q.js → cli-jf7wmv1r.js} +2521 -733
- package/dist/{cli-qwsnmjdd.js → cli-jxs2ygaw.js} +168 -61
- package/dist/cli-kdkwd3yr.js +615 -0
- package/dist/{cli-hgmnf38m.js → cli-kh04eqwe.js} +1 -1
- package/dist/{cli-0383bp7f.js → cli-kp42yek0.js} +425 -90
- package/dist/{cli-ybgv4kw2.js → cli-mbssg1xv.js} +9 -2
- package/dist/{cli-jsp14b6c.js → cli-mzfth0bp.js} +38 -1
- package/dist/{cli-8g6mc14k.js → cli-nq7yqw7b.js} +16 -5
- package/dist/{cli-ee8bc46j.js → cli-nt8hecg9.js} +33 -32
- package/dist/{cli-9s68w752.js → cli-p0r8sxj5.js} +1 -1
- package/dist/{cli-58e8yr0d.js → cli-pkd6s2wy.js} +4 -2
- package/dist/{cli-17xpf7cm.js → cli-q24re23n.js} +6 -4
- package/dist/cli-qjjvzwzd.js +222 -0
- package/dist/cli-qrgt5bqf.js +189 -0
- package/dist/{cli-et8m9xsv.js → cli-rfjw9jw2.js} +22 -10
- package/dist/{cli-798vkbks.js → cli-rj0gqwwp.js} +2 -2
- package/dist/{cli-121ph74b.js → cli-rnyzn44p.js} +1 -1
- package/dist/{cli-vnnz5ncg.js → cli-s3hwpt9v.js} +95 -16
- package/dist/{cli-7119ja3w.js → cli-sptrj5f5.js} +2 -2
- package/dist/{cli-a8c82p2t.js → cli-sqvfa090.js} +13 -6
- package/dist/{cli-phfg8vy8.js → cli-swfmp1fx.js} +26 -26
- package/dist/{cli-nsdxtcbg.js → cli-tcen9p5w.js} +8 -5
- package/dist/{cli-9m03pzga.js → cli-vc9bp061.js} +5 -5
- package/dist/{cli-rtx9dygp.js → cli-vxe6qa4h.js} +1 -1
- package/dist/{cli-ecfxwnyb.js → cli-vy3ed88x.js} +1 -1
- package/dist/{cli-0c7ata9h.js → cli-vyqs9ya8.js} +6 -6
- package/dist/cli-wbvnn6tk.js +861 -0
- package/dist/{cli-kgd3p0bm.js → cli-wjgs5wxe.js} +6 -6
- package/dist/cli-wxkkd0e0.js +33 -0
- package/dist/{cli-akfj32vj.js → cli-xqpk3rp7.js} +5 -4
- package/dist/cli-zp38xvtm.js +9487 -0
- package/dist/{cli-3zc55ctx.js → cli-zvhd3f64.js} +2 -2
- package/dist/{cli-ze1d3wzv.js → cli-zxqx25nz.js} +5 -5
- package/dist/cli.js +32 -25
- package/dist/{cloudflare-workers-ai-system-one-k6fbxw9d.js → cloudflare-workers-ai-system-one-k8ddqjkq.js} +8 -6
- package/dist/{codex-adapter-04v97vep.js → codex-adapter-esvmnhn7.js} +13 -13
- package/dist/{codex-hook-20me6byw.js → codex-hook-m73jmqgx.js} +19 -3
- package/dist/{codex-session-runner-ax69mqq3.js → codex-session-runner-ban9excr.js} +13 -13
- package/dist/{commands-18gy08n1.js → commands-75413p2n.js} +9 -7
- package/dist/{db-d2pvjpyt.js → db-7f5pecwg.js} +11 -7
- package/dist/{dispatcher-8wbwyzjg.js → dispatcher-fh9at8nz.js} +33 -30
- package/dist/{dsh-adapter-ysqk9snv.js → dsh-adapter-wpze65d4.js} +3 -3
- package/dist/{e2b-esmt1r16.js → e2b-gjgdjp91.js} +10 -9
- package/dist/{pi-logo-animation-e9a0t68z.js → easter-egg-3d-3w5c2n4c.js} +122 -68
- package/dist/{execute-cmagrbrz.js → execute-f50wg9nh.js} +29 -6
- package/dist/{google-generative-ai-k6n5nbpp.js → google-generative-ai-jxxpnegy.js} +2 -2
- package/dist/{google-vertex-44xxtcvp.js → google-vertex-x7q1angm.js} +5 -5
- package/dist/{handlers-6b69m5kp.js → handlers-w13787bb.js} +36 -33
- package/dist/{hook-srnkt0hb.js → hook-46g1jrvx.js} +28 -15
- package/dist/{hook-k3mydf2n.js → hook-4tpts2ry.js} +20 -17
- package/dist/{http-hrf80ca1.js → http-19pzbfrt.js} +964 -176
- package/dist/{index-9rxdvj6w.js → index-8fd4dm4h.js} +34 -31
- package/dist/{index-1asbaj62.js → index-dqmp15bq.js} +35 -32
- package/dist/{index-45evq4ee.js → index-qb4q1y3e.js} +33 -30
- package/dist/{index-k4nj18qs.js → index-s75wc4wd.js} +12 -10
- package/dist/{index-xmevk2b9.js → index-scptvdxg.js} +27 -25
- package/dist/{keepalive-sfdz6ksp.js → keepalive-xa5ts9cd.js} +11 -9
- package/dist/{lead-ej91e9hw.js → lead-qp4xkvjq.js} +45 -41
- package/dist/{maintenance-80dnd7ke.js → maintenance-rm5885hm.js} +13 -11
- package/dist/{mistral-conversations-pc3ntnxt.js → mistral-conversations-rw7fbt22.js} +4 -4
- package/dist/{node-a5e00qhp.js → node-8zkdq42h.js} +834 -833
- package/dist/{oauth-refresh-sweep-m1etk2mx.js → oauth-refresh-sweep-1csdfebf.js} +10 -8
- package/dist/{onboard-0q8sdfqc.js → onboard-r6b78cs4.js} +10 -10
- package/dist/{openai-codex-responses-n7n683p2.js → openai-codex-responses-efvrgrq3.js} +4 -4
- package/dist/{openai-completions-jpaj5g2b.js → openai-completions-bbbrrz4z.js} +12 -8
- package/dist/{openai-responses-9a3nme9j.js → openai-responses-9jjyrcm1.js} +15 -10
- package/dist/{opencode-adapter-4wcgmvgf.js → opencode-adapter-kb3g6pkf.js} +2 -2
- package/dist/{otel-impl-hnqm9mxq.js → otel-impl-0y9knpky.js} +5 -5
- package/dist/{pi-mono-adapter-d6sqm9wr.js → pi-mono-adapter-z6s1bme2.js} +100 -19
- package/dist/{pricing-refresh-y60z64pm.js → pricing-refresh-mth26tgp.js} +11 -9
- package/dist/{rbac-roles-8pa0754b.js → rbac-roles-kwntb23q.js} +13 -9
- package/dist/rbac-roles-vpk4ebm8.js +45 -0
- package/dist/{registry-v5wytg0s.js → registry-km6t1jd2.js} +1 -1
- package/dist/{render-v2-418s1vj7.js → render-v2-hcm7h00y.js} +13 -11
- package/dist/{runtime-3pknc168.js → runtime-vfjsfsc5.js} +4 -4
- package/dist/{setup-yghdkj4x.js → setup-hbyzd65h.js} +2 -2
- package/dist/{virtual-modules-jcceevt5.js → virtual-modules-9bv7ry91.js} +9 -8
- package/dist/{work-objects-j1sz20v9.js → work-objects-m09sxnhe.js} +36 -33
- package/dist/{worker-d4gcvkjt.js → worker-0h35rvk4.js} +45 -41
- package/openapi.json +491 -12
- package/package.json +13 -6
- package/src/be/api-drain.ts +184 -0
- package/src/be/db/tasks/read.ts +31 -6
- package/src/be/db/tasks/write.ts +5 -4
- package/src/be/db.ts +42 -18
- package/src/be/memory/link-resolver.ts +29 -3
- package/src/be/migrations/192_favorites_agent_fs_path.sql +36 -0
- package/src/be/onboarding.ts +7 -1
- package/src/be/seed-prompt-templates.ts +1 -0
- package/src/be/swarm-config-guard.ts +35 -16
- package/src/be/task-citations.ts +11 -5
- package/src/be/users.ts +3 -0
- package/src/cli.tsx +3 -2
- package/src/comb/agent-fs.ts +39 -0
- package/src/comb/config.ts +37 -0
- package/src/comb/markers.ts +37 -0
- package/src/comb/review-batch.ts +486 -0
- package/src/comb/templates.ts +88 -0
- package/src/commands/context-preamble.ts +4 -1
- package/src/commands/hook.ts +2 -1
- package/src/commands/runner.ts +262 -68
- package/src/commands/templates.ts +10 -0
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/fs/agent-fs-provider.ts +106 -2
- package/src/heartbeat/heartbeat.ts +4 -0
- package/src/hooks/codex-hook.ts +25 -1
- package/src/hooks/hook.ts +13 -0
- package/src/hooks/pr-body-guard.ts +983 -0
- package/src/http/all-routes.ts +1 -0
- package/src/http/assets.ts +5 -17
- package/src/http/comb.ts +131 -0
- package/src/http/core.ts +2 -1
- package/src/http/fs.ts +9 -17
- package/src/http/index.ts +23 -9
- package/src/http/onboarding.ts +21 -2
- package/src/http/poll.ts +30 -3
- package/src/http/realtime.ts +62 -0
- package/src/http/request-principal.ts +22 -0
- package/src/http/status.ts +34 -2
- package/src/kv-reserved-namespaces.ts +10 -5
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +123 -0
- package/src/rbac/legacy-policy.ts +1 -0
- package/src/rbac/permissions.ts +4 -0
- package/src/realtime/auth.ts +63 -1
- package/src/realtime/tickets.ts +52 -0
- package/src/realtime/transport.ts +51 -22
- package/src/scripts-runtime/types/swarm-extension.d.ts +3 -1
- package/src/telemetry-context.ts +175 -0
- package/src/telemetry-contract/EVENTS.md +81 -0
- package/src/telemetry-contract/SOURCE.json +9 -0
- package/src/telemetry-contract/catalog.json +1335 -0
- package/src/telemetry-contract/index.ts +60 -0
- package/src/telemetry-contract/types.gen.ts +1971 -0
- package/src/telemetry-identity.ts +129 -0
- package/src/telemetry-snapshot.ts +211 -0
- package/src/telemetry-trigger.ts +125 -0
- package/src/telemetry-types.typetest.ts +40 -0
- package/src/telemetry.ts +271 -67
- package/src/tests/api-drain-e2e.test.ts +249 -0
- package/src/tests/api-drain-worker-e2e.test.ts +216 -0
- package/src/tests/api-drain.test.ts +351 -0
- package/src/tests/comb-config-guard.test.ts +38 -0
- package/src/tests/comb-links.test.ts +162 -0
- package/src/tests/comb-markers.test.ts +68 -0
- package/src/tests/comb-review-batch.test.ts +770 -0
- package/src/tests/comb-status.test.ts +152 -0
- package/src/tests/db-task-read-contract.test.ts +2 -0
- package/src/tests/db-task-write-contract.test.ts +1 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/favorites-agent-fs-path.test.ts +226 -0
- package/src/tests/memory-link-resolver.test.ts +34 -0
- package/src/tests/onboarding.test.ts +108 -0
- package/src/tests/pi-mono-adapter.test.ts +265 -2
- package/src/tests/pr-body-leaks.test.ts +789 -0
- package/src/tests/preload.ts +8 -0
- package/src/tests/rbac-engine.test.ts +19 -0
- package/src/tests/realtime-room-auth.test.ts +104 -1
- package/src/tests/realtime-tickets.test.ts +74 -0
- package/src/tests/realtime-transport.test.ts +131 -4
- package/src/tests/runner-api-drain.test.ts +301 -0
- package/src/tests/runner-fallback-output.test.ts +61 -0
- package/src/tests/runner-fenced-json-output.test.ts +178 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/send-task-requested-by.test.ts +133 -15
- package/src/tests/task-lifecycle-telemetry.test.ts +8 -3
- package/src/tests/telemetry-context.test.ts +314 -0
- package/src/tests/telemetry-init.test.ts +51 -19
- package/src/tests/telemetry-snapshot.test.ts +229 -0
- package/src/tests/telemetry-trigger.test.ts +200 -0
- package/src/tests/template-registry-helpers.ts +1 -0
- package/src/tests/workflow-agent-task-retry.test.ts +363 -0
- package/src/tests/workflow-definition-telemetry.test.ts +4 -4
- package/src/tools/poll-task.ts +20 -1
- package/src/tools/send-task.ts +9 -1
- package/src/tools/task-action.ts +11 -0
- package/src/types.ts +2 -1
- package/src/utils/api-drain.ts +24 -0
- package/src/utils/constants.ts +57 -16
- package/src/utils/pr-body-leaks.ts +172 -0
- package/src/utils/task-attachment-links.ts +14 -8
- package/src/workflows/recovery.ts +26 -1
- package/src/workflows/resume.ts +31 -4
- package/src/workflows/task-step-routing.ts +75 -2
- package/tsconfig.json +3 -0
- package/dist/cli-tjf4cn8z.js +0 -9119
- package/dist/cli-tsk3nz8a.js +0 -582
- package/dist/{cli-t6v2ah3r.js → cli-twrh04ap.js} +3 -3
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API drain, server side: the process-local state, the bounded wait for worker
|
|
3
|
+
* handoffs, and the rule that a draining API dispatches no new work through any
|
|
4
|
+
* of the three paths that start or claim a task (HTTP poll, MCP poll-task, MCP
|
|
5
|
+
* task-action claim and accept).
|
|
6
|
+
*/
|
|
7
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { unlink } from "node:fs/promises";
|
|
9
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
10
|
+
import {
|
|
11
|
+
beginApiDrain,
|
|
12
|
+
countStillInProgress,
|
|
13
|
+
DEFAULT_API_DRAIN_MAX_MS,
|
|
14
|
+
drainApi,
|
|
15
|
+
isApiDraining,
|
|
16
|
+
listLiveInFlightTaskIds,
|
|
17
|
+
resetApiDrainForTesting,
|
|
18
|
+
resolveApiDrainMaxMs,
|
|
19
|
+
} from "../be/api-drain";
|
|
20
|
+
import {
|
|
21
|
+
closeDb,
|
|
22
|
+
createAgent,
|
|
23
|
+
createTaskExtended,
|
|
24
|
+
getDbClient,
|
|
25
|
+
getTaskById,
|
|
26
|
+
initDb,
|
|
27
|
+
startTask,
|
|
28
|
+
} from "../be/db";
|
|
29
|
+
import { validateConfigValue } from "../be/swarm-config-guard";
|
|
30
|
+
import { handlePoll } from "../http/poll";
|
|
31
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
32
|
+
import { registerPollTaskTool } from "../tools/poll-task";
|
|
33
|
+
import { registerTaskActionTool } from "../tools/task-action";
|
|
34
|
+
import { API_DRAIN_MAX_MS_LIMIT } from "../utils/api-drain";
|
|
35
|
+
import { listenOnFreePort } from "./test-net";
|
|
36
|
+
|
|
37
|
+
const TEST_DB_PATH = "./test-api-drain.sqlite";
|
|
38
|
+
|
|
39
|
+
async function removeDbFiles(): Promise<void> {
|
|
40
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
41
|
+
await unlink(TEST_DB_PATH + suffix).catch(() => {});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class MockMcpServer {
|
|
46
|
+
handlers = new Map<string, (args: unknown, extra: unknown) => Promise<unknown>>();
|
|
47
|
+
registerTool(
|
|
48
|
+
name: string,
|
|
49
|
+
_config: unknown,
|
|
50
|
+
handler: (args: unknown, extra: unknown) => Promise<unknown>,
|
|
51
|
+
) {
|
|
52
|
+
this.handlers.set(name, handler);
|
|
53
|
+
return { name };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let server: Server;
|
|
58
|
+
let baseUrl = "";
|
|
59
|
+
const mcp = new MockMcpServer();
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
await removeDbFiles();
|
|
63
|
+
initDb(TEST_DB_PATH);
|
|
64
|
+
registerPollTaskTool(mcp as unknown as Parameters<typeof registerPollTaskTool>[0]);
|
|
65
|
+
registerTaskActionTool(mcp as unknown as Parameters<typeof registerTaskActionTool>[0]);
|
|
66
|
+
server = createHttpServer(async (req, res) => {
|
|
67
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
68
|
+
const handled = await handlePoll(
|
|
69
|
+
req,
|
|
70
|
+
res,
|
|
71
|
+
getPathSegments(req.url || ""),
|
|
72
|
+
parseQueryParams(req.url || ""),
|
|
73
|
+
myAgentId,
|
|
74
|
+
);
|
|
75
|
+
if (!handled) {
|
|
76
|
+
res.writeHead(404);
|
|
77
|
+
res.end();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
85
|
+
closeDb();
|
|
86
|
+
await removeDbFiles();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
resetApiDrainForTesting();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
async function makeAgent(opts: { isLead?: boolean; lastUpdatedAt?: string; status?: string } = {}) {
|
|
94
|
+
const id = crypto.randomUUID();
|
|
95
|
+
await createAgent({
|
|
96
|
+
id,
|
|
97
|
+
name: "drain-test-agent",
|
|
98
|
+
isLead: opts.isLead ?? false,
|
|
99
|
+
status: "idle",
|
|
100
|
+
capabilities: [],
|
|
101
|
+
maxTasks: 1,
|
|
102
|
+
});
|
|
103
|
+
if (opts.lastUpdatedAt || opts.status) {
|
|
104
|
+
await getDbClient().run(
|
|
105
|
+
"UPDATE agents SET lastUpdatedAt = COALESCE(?, lastUpdatedAt), status = COALESCE(?, status) WHERE id = ?",
|
|
106
|
+
[opts.lastUpdatedAt ?? null, opts.status ?? null, id],
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return id;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function poll(agentId: string) {
|
|
113
|
+
const res = await fetch(`${baseUrl}/api/poll`, { headers: { "X-Agent-ID": agentId } });
|
|
114
|
+
return (await res.json()) as { trigger: { type: string; taskId?: string } | null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function callTool(name: string, args: unknown, agentId: string) {
|
|
118
|
+
const handler = mcp.handlers.get(name);
|
|
119
|
+
if (!handler) throw new Error(`${name} not registered`);
|
|
120
|
+
return (await handler(args, {
|
|
121
|
+
sessionId: "test-session",
|
|
122
|
+
requestInfo: { headers: { "x-agent-id": agentId } },
|
|
123
|
+
sendNotification: async () => {},
|
|
124
|
+
})) as { isError?: boolean; content?: Array<{ text?: string }> };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe("drain state", () => {
|
|
128
|
+
test("starts not draining; begin is idempotent; reset clears it", () => {
|
|
129
|
+
expect(isApiDraining()).toBe(false);
|
|
130
|
+
expect(beginApiDrain()).toBe(true);
|
|
131
|
+
expect(isApiDraining()).toBe(true);
|
|
132
|
+
expect(beginApiDrain()).toBe(false);
|
|
133
|
+
resetApiDrainForTesting();
|
|
134
|
+
expect(isApiDraining()).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("API_DRAIN_MAX_MS: opt-in, so unset, empty, or invalid means 0 (off)", () => {
|
|
138
|
+
expect(DEFAULT_API_DRAIN_MAX_MS).toBe(0);
|
|
139
|
+
expect(resolveApiDrainMaxMs({})).toBe(0);
|
|
140
|
+
expect(resolveApiDrainMaxMs({ API_DRAIN_MAX_MS: "" })).toBe(0);
|
|
141
|
+
expect(resolveApiDrainMaxMs({ API_DRAIN_MAX_MS: " " })).toBe(0);
|
|
142
|
+
expect(resolveApiDrainMaxMs({ API_DRAIN_MAX_MS: "abc" })).toBe(0);
|
|
143
|
+
expect(resolveApiDrainMaxMs({ API_DRAIN_MAX_MS: "-5" })).toBe(0);
|
|
144
|
+
expect(resolveApiDrainMaxMs({ API_DRAIN_MAX_MS: "0" })).toBe(0);
|
|
145
|
+
expect(resolveApiDrainMaxMs({ API_DRAIN_MAX_MS: "5000" })).toBe(5000);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("API_DRAIN_MAX_MS config", () => {
|
|
150
|
+
test("the validator and the resolver share one range", () => {
|
|
151
|
+
expect(validateConfigValue("API_DRAIN_MAX_MS", "0")).toBeNull();
|
|
152
|
+
expect(validateConfigValue("API_DRAIN_MAX_MS", "30000")).toBeNull();
|
|
153
|
+
expect(validateConfigValue("API_DRAIN_MAX_MS", String(API_DRAIN_MAX_MS_LIMIT))).toBeNull();
|
|
154
|
+
expect(validateConfigValue("API_DRAIN_MAX_MS", String(API_DRAIN_MAX_MS_LIMIT + 1))).toContain(
|
|
155
|
+
`between 0 and ${API_DRAIN_MAX_MS_LIMIT}`,
|
|
156
|
+
);
|
|
157
|
+
expect(validateConfigValue("API_DRAIN_MAX_MS", "-1")).not.toBeNull();
|
|
158
|
+
expect(validateConfigValue("API_DRAIN_MAX_MS", "soon")).not.toBeNull();
|
|
159
|
+
// A value past the limit would be clamped at shutdown, never silently honored.
|
|
160
|
+
expect(resolveApiDrainMaxMs({ API_DRAIN_MAX_MS: "9999999" })).toBe(API_DRAIN_MAX_MS_LIMIT);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("drainApi", () => {
|
|
165
|
+
/** A fake clock: sleeping advances it, so the wait is deterministic. */
|
|
166
|
+
function fakeClock() {
|
|
167
|
+
let t = 1_000;
|
|
168
|
+
return {
|
|
169
|
+
now: () => t,
|
|
170
|
+
sleep: async (ms: number) => {
|
|
171
|
+
t += ms;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test("off: does not enter the draining state and reads nothing", async () => {
|
|
177
|
+
// Unset is the default and must behave exactly like an explicit 0.
|
|
178
|
+
for (const env of [{}, { API_DRAIN_MAX_MS: "0" }, { API_DRAIN_MAX_MS: "garbage" }]) {
|
|
179
|
+
let reads = 0;
|
|
180
|
+
const outcome = await drainApi({
|
|
181
|
+
env,
|
|
182
|
+
log: () => {},
|
|
183
|
+
listInFlight: async () => {
|
|
184
|
+
reads++;
|
|
185
|
+
return ["in-flight"];
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
expect(outcome.enabled).toBe(false);
|
|
189
|
+
expect(isApiDraining()).toBe(false);
|
|
190
|
+
expect(reads).toBe(0);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("no in-flight tasks: drains at once without waiting", async () => {
|
|
195
|
+
const clock = fakeClock();
|
|
196
|
+
const outcome = await drainApi({
|
|
197
|
+
...clock,
|
|
198
|
+
env: { API_DRAIN_MAX_MS: "30000" },
|
|
199
|
+
log: () => {},
|
|
200
|
+
listInFlight: async () => [],
|
|
201
|
+
});
|
|
202
|
+
expect(isApiDraining()).toBe(true);
|
|
203
|
+
expect(outcome).toMatchObject({ enabled: true, inFlight: 0, remaining: 0, timedOut: false });
|
|
204
|
+
expect(clock.now()).toBe(1_000);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("returns as soon as every in-flight task is handed off", async () => {
|
|
208
|
+
const clock = fakeClock();
|
|
209
|
+
const remainingByRead = [2, 1, 0];
|
|
210
|
+
let reads = 0;
|
|
211
|
+
const outcome = await drainApi({
|
|
212
|
+
...clock,
|
|
213
|
+
env: { API_DRAIN_MAX_MS: "30000" },
|
|
214
|
+
log: () => {},
|
|
215
|
+
listInFlight: async () => ["a", "b"],
|
|
216
|
+
countRemaining: async () => remainingByRead[reads++] ?? 0,
|
|
217
|
+
});
|
|
218
|
+
expect(outcome).toMatchObject({ inFlight: 2, remaining: 0, timedOut: false });
|
|
219
|
+
expect(outcome.waitedMs).toBe(1_000);
|
|
220
|
+
expect(isApiDraining()).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("gives up at the cap and reports what was left", async () => {
|
|
224
|
+
const clock = fakeClock();
|
|
225
|
+
const outcome = await drainApi({
|
|
226
|
+
...clock,
|
|
227
|
+
env: { API_DRAIN_MAX_MS: "3000" },
|
|
228
|
+
log: () => {},
|
|
229
|
+
listInFlight: async () => ["a", "b"],
|
|
230
|
+
countRemaining: async () => 1,
|
|
231
|
+
});
|
|
232
|
+
expect(outcome).toMatchObject({ inFlight: 2, remaining: 1, timedOut: true });
|
|
233
|
+
expect(outcome.waitedMs).toBe(3_000);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a failing read ends the wait instead of blocking shutdown", async () => {
|
|
237
|
+
const lines: string[] = [];
|
|
238
|
+
const clock = fakeClock();
|
|
239
|
+
const outcome = await drainApi({
|
|
240
|
+
...clock,
|
|
241
|
+
env: { API_DRAIN_MAX_MS: "30000" },
|
|
242
|
+
log: (line) => lines.push(line),
|
|
243
|
+
listInFlight: async () => {
|
|
244
|
+
throw new Error("database is locked");
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
expect(outcome).toMatchObject({ enabled: true, inFlight: 0, timedOut: false });
|
|
248
|
+
expect(lines.join("\n")).toContain("database is locked");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("in-flight reads", () => {
|
|
253
|
+
test("only in_progress tasks held by a worker that pinged recently count", async () => {
|
|
254
|
+
const live = await makeAgent();
|
|
255
|
+
const silent = await makeAgent({
|
|
256
|
+
lastUpdatedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
257
|
+
});
|
|
258
|
+
const offline = await makeAgent({ status: "offline" });
|
|
259
|
+
const idleLive = await makeAgent();
|
|
260
|
+
|
|
261
|
+
const liveTask = await createTaskExtended("live", { agentId: live });
|
|
262
|
+
const silentTask = await createTaskExtended("silent", { agentId: silent });
|
|
263
|
+
const offlineTask = await createTaskExtended("offline", { agentId: offline });
|
|
264
|
+
const pendingTask = await createTaskExtended("pending", { agentId: idleLive });
|
|
265
|
+
for (const task of [liveTask, silentTask, offlineTask]) await startTask(task.id);
|
|
266
|
+
|
|
267
|
+
const ids = await listLiveInFlightTaskIds();
|
|
268
|
+
expect(ids).toContain(liveTask.id);
|
|
269
|
+
expect(ids).not.toContain(silentTask.id);
|
|
270
|
+
expect(ids).not.toContain(offlineTask.id);
|
|
271
|
+
expect(ids).not.toContain(pendingTask.id);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("countStillInProgress drops a task once it leaves in_progress", async () => {
|
|
275
|
+
const agent = await makeAgent();
|
|
276
|
+
const task = await createTaskExtended("to-hand-off", { agentId: agent });
|
|
277
|
+
await startTask(task.id);
|
|
278
|
+
expect(await countStillInProgress([task.id])).toBe(1);
|
|
279
|
+
expect(await countStillInProgress([])).toBe(0);
|
|
280
|
+
await getDbClient().run("UPDATE agent_tasks SET status = 'superseded' WHERE id = ?", [task.id]);
|
|
281
|
+
expect(await countStillInProgress([task.id])).toBe(0);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("a draining API dispatches nothing", () => {
|
|
286
|
+
test("HTTP poll starts no assigned task, then does once the drain ends", async () => {
|
|
287
|
+
const agent = await makeAgent();
|
|
288
|
+
const task = await createTaskExtended("assigned", { agentId: agent });
|
|
289
|
+
beginApiDrain();
|
|
290
|
+
|
|
291
|
+
expect((await poll(agent)).trigger).toBeNull();
|
|
292
|
+
expect((await getTaskById(task.id))?.status).toBe("pending");
|
|
293
|
+
|
|
294
|
+
resetApiDrainForTesting();
|
|
295
|
+
const after = await poll(agent);
|
|
296
|
+
expect(after.trigger?.type).toBe("task_assigned");
|
|
297
|
+
expect(after.trigger?.taskId).toBe(task.id);
|
|
298
|
+
expect((await getTaskById(task.id))?.status).toBe("in_progress");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("HTTP poll claims no pool task while draining", async () => {
|
|
302
|
+
const agent = await makeAgent();
|
|
303
|
+
const task = await createTaskExtended("pool task");
|
|
304
|
+
beginApiDrain();
|
|
305
|
+
expect((await poll(agent)).trigger).toBeNull();
|
|
306
|
+
expect((await getTaskById(task.id))?.agentId ?? null).toBeNull();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a poll still answers 404 for an unknown agent while draining", async () => {
|
|
310
|
+
beginApiDrain();
|
|
311
|
+
const res = await fetch(`${baseUrl}/api/poll`, {
|
|
312
|
+
headers: { "X-Agent-ID": crypto.randomUUID() },
|
|
313
|
+
});
|
|
314
|
+
expect(res.status).toBe(404);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("MCP poll-task starts no pending task while draining", async () => {
|
|
318
|
+
const agent = await makeAgent();
|
|
319
|
+
const task = await createTaskExtended("mcp-assigned", { agentId: agent });
|
|
320
|
+
beginApiDrain();
|
|
321
|
+
|
|
322
|
+
const result = await callTool("poll-task", {}, agent);
|
|
323
|
+
expect(result.isError).not.toBe(true);
|
|
324
|
+
expect(JSON.stringify(result)).toContain("draining");
|
|
325
|
+
expect((await getTaskById(task.id))?.status).toBe("pending");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("MCP task-action claim is refused while draining", async () => {
|
|
329
|
+
const agent = await makeAgent();
|
|
330
|
+
const task = await createTaskExtended("mcp-pool");
|
|
331
|
+
beginApiDrain();
|
|
332
|
+
|
|
333
|
+
const result = await callTool("task-action", { action: "claim", taskId: task.id }, agent);
|
|
334
|
+
expect(JSON.stringify(result)).toContain("draining");
|
|
335
|
+
expect((await getTaskById(task.id))?.agentId ?? null).toBeNull();
|
|
336
|
+
|
|
337
|
+
resetApiDrainForTesting();
|
|
338
|
+
await callTool("task-action", { action: "claim", taskId: task.id }, agent);
|
|
339
|
+
expect((await getTaskById(task.id))?.agentId).toBe(agent);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("MCP task-action accept is refused while draining", async () => {
|
|
343
|
+
const agent = await makeAgent();
|
|
344
|
+
const task = await createTaskExtended("offered", { offeredTo: agent });
|
|
345
|
+
beginApiDrain();
|
|
346
|
+
|
|
347
|
+
const result = await callTool("task-action", { action: "accept", taskId: task.id }, agent);
|
|
348
|
+
expect(JSON.stringify(result)).toContain("draining");
|
|
349
|
+
expect((await getTaskById(task.id))?.status).toBe("offered");
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateConfigValue } from "../be/swarm-config-guard";
|
|
3
|
+
|
|
4
|
+
describe("Comb config keys", () => {
|
|
5
|
+
test("COMB_ENABLED accepts boolean literals only", () => {
|
|
6
|
+
for (const value of ["true", "false", "1", "0", " TRUE "]) {
|
|
7
|
+
expect(validateConfigValue("COMB_ENABLED", value)).toBeNull();
|
|
8
|
+
}
|
|
9
|
+
for (const value of ["yes", "on", ""]) {
|
|
10
|
+
expect(validateConfigValue("COMB_ENABLED", value)).toContain("Invalid COMB_ENABLED");
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("AGENT_FS_PUBLIC_URL accepts blank or an http(s) base URL", () => {
|
|
15
|
+
for (const value of ["", "http://localhost:7433", "https://agent-fs.example.com/base"]) {
|
|
16
|
+
expect(validateConfigValue("AGENT_FS_PUBLIC_URL", value)).toBeNull();
|
|
17
|
+
}
|
|
18
|
+
for (const value of [
|
|
19
|
+
"agent-fs.example.com",
|
|
20
|
+
"not a url",
|
|
21
|
+
"ftp://agent-fs.example.com",
|
|
22
|
+
"https://agent-fs.example.com/?x=1",
|
|
23
|
+
"https://agent-fs.example.com/#frag",
|
|
24
|
+
]) {
|
|
25
|
+
expect(validateConfigValue("AGENT_FS_PUBLIC_URL", value)).toContain(
|
|
26
|
+
"Invalid AGENT_FS_PUBLIC_URL",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("OPENROUTER_BASE_URL keeps its rules after sharing the URL validator", () => {
|
|
32
|
+
expect(validateConfigValue("OPENROUTER_BASE_URL", "")).toBeNull();
|
|
33
|
+
expect(validateConfigValue("OPENROUTER_BASE_URL", "https://api.example.com/v1")).toBeNull();
|
|
34
|
+
expect(validateConfigValue("OPENROUTER_BASE_URL", "https://api.example.com/v1?x=1")).toContain(
|
|
35
|
+
"Invalid OPENROUTER_BASE_URL",
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildCitationUrl } from "../be/task-citations";
|
|
3
|
+
import type { TaskAttachment } from "../types";
|
|
4
|
+
import { buildAgentFsLiveUrl, buildCombFileUrl } from "../utils/constants";
|
|
5
|
+
import { taskAttachmentDisplayUrl } from "../utils/task-attachment-links";
|
|
6
|
+
|
|
7
|
+
const ENV_KEYS = [
|
|
8
|
+
"COMB_ENABLED",
|
|
9
|
+
"APP_URL",
|
|
10
|
+
"DASHBOARD_URL",
|
|
11
|
+
"AGENT_FS_API_URL",
|
|
12
|
+
"AGENT_FS_LIVE_URL",
|
|
13
|
+
"AGENT_FS_DEFAULT_ORG_ID",
|
|
14
|
+
"AGENT_FS_DEFAULT_DRIVE_ID",
|
|
15
|
+
] as const;
|
|
16
|
+
const saved = new Map<string, string | undefined>();
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
for (const key of ENV_KEYS) {
|
|
20
|
+
saved.set(key, process.env[key]);
|
|
21
|
+
delete process.env[key];
|
|
22
|
+
}
|
|
23
|
+
process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
|
|
24
|
+
process.env.AGENT_FS_LIVE_URL = "https://live.example.test/";
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
for (const key of ENV_KEYS) {
|
|
29
|
+
const value = saved.get(key);
|
|
30
|
+
if (value === undefined) delete process.env[key];
|
|
31
|
+
else process.env[key] = value;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function agentFsAttachment(overrides: Partial<TaskAttachment> = {}): TaskAttachment {
|
|
36
|
+
return {
|
|
37
|
+
id: "att-1",
|
|
38
|
+
taskId: "task-1",
|
|
39
|
+
kind: "agent-fs",
|
|
40
|
+
name: "notes.md",
|
|
41
|
+
providerId: "agent-fs",
|
|
42
|
+
path: "comb-qa/notes.md",
|
|
43
|
+
orgId: "org-1",
|
|
44
|
+
driveId: "drive-1",
|
|
45
|
+
...overrides,
|
|
46
|
+
} as TaskAttachment;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LIVE = "https://live.example.test/file/~/org-1/drive-1/comb-qa/notes.md";
|
|
50
|
+
const COMB = "http://localhost:5274/file/~/org-1/drive-1/comb-qa/notes.md";
|
|
51
|
+
|
|
52
|
+
describe("taskAttachmentDisplayUrl for agent-fs attachments", () => {
|
|
53
|
+
test("flag off: the live URL, even with APP_URL set", () => {
|
|
54
|
+
process.env.APP_URL = "http://localhost:5274";
|
|
55
|
+
expect(taskAttachmentDisplayUrl(agentFsAttachment())).toBe(LIVE);
|
|
56
|
+
process.env.COMB_ENABLED = "false";
|
|
57
|
+
expect(taskAttachmentDisplayUrl(agentFsAttachment())).toBe(LIVE);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("flag on with APP_URL: the dashboard URL", () => {
|
|
61
|
+
process.env.COMB_ENABLED = "true";
|
|
62
|
+
process.env.APP_URL = "http://localhost:5274/";
|
|
63
|
+
expect(taskAttachmentDisplayUrl(agentFsAttachment())).toBe(COMB);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("the deprecated DASHBOARD_URL also counts as a configured app URL", () => {
|
|
67
|
+
process.env.COMB_ENABLED = "true";
|
|
68
|
+
process.env.DASHBOARD_URL = "http://localhost:5274";
|
|
69
|
+
expect(taskAttachmentDisplayUrl(agentFsAttachment())).toBe(COMB);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("flag on without APP_URL: the live URL (never the hosted default dashboard)", () => {
|
|
73
|
+
process.env.COMB_ENABLED = "true";
|
|
74
|
+
expect(taskAttachmentDisplayUrl(agentFsAttachment())).toBe(LIVE);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("flag on without an agent-fs API URL: Comb is off, so the live URL", () => {
|
|
78
|
+
process.env.COMB_ENABLED = "true";
|
|
79
|
+
process.env.APP_URL = "http://localhost:5274";
|
|
80
|
+
delete process.env.AGENT_FS_API_URL;
|
|
81
|
+
expect(taskAttachmentDisplayUrl(agentFsAttachment())).toBe(LIVE);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("comb: false keeps the live URL", () => {
|
|
85
|
+
process.env.COMB_ENABLED = "true";
|
|
86
|
+
process.env.APP_URL = "http://localhost:5274";
|
|
87
|
+
expect(taskAttachmentDisplayUrl(agentFsAttachment(), { comb: false })).toBe(LIVE);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("row ids override env ids, and a partial row never mixes with them", () => {
|
|
91
|
+
process.env.COMB_ENABLED = "true";
|
|
92
|
+
process.env.APP_URL = "http://localhost:5274";
|
|
93
|
+
process.env.AGENT_FS_DEFAULT_ORG_ID = "env-org";
|
|
94
|
+
process.env.AGENT_FS_DEFAULT_DRIVE_ID = "env-drive";
|
|
95
|
+
expect(taskAttachmentDisplayUrl(agentFsAttachment())).toBe(COMB);
|
|
96
|
+
expect(
|
|
97
|
+
taskAttachmentDisplayUrl(agentFsAttachment({ orgId: undefined, driveId: undefined })),
|
|
98
|
+
).toBe("http://localhost:5274/file/~/env-org/env-drive/comb-qa/notes.md");
|
|
99
|
+
expect(taskAttachmentDisplayUrl(agentFsAttachment({ driveId: undefined }))).toBe(
|
|
100
|
+
"agent-fs:comb-qa/notes.md",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("encoding keeps existing %HH escapes and encodes raw text", () => {
|
|
105
|
+
process.env.COMB_ENABLED = "true";
|
|
106
|
+
process.env.APP_URL = "http://localhost:5274";
|
|
107
|
+
expect(
|
|
108
|
+
taskAttachmentDisplayUrl(
|
|
109
|
+
agentFsAttachment({ path: "/shared%20reports/final report 100%.md" }),
|
|
110
|
+
),
|
|
111
|
+
).toBe(
|
|
112
|
+
"http://localhost:5274/file/~/org-1/drive-1/shared%20reports/final%20report%20100%25.md",
|
|
113
|
+
);
|
|
114
|
+
expect(
|
|
115
|
+
buildCombFileUrl({ path: "reports/café #1?.md", orgId: "org-1", driveId: "drive-1" }),
|
|
116
|
+
).toBe("http://localhost:5274/file/~/org-1/drive-1/reports/caf%C3%A9%20%231%3F.md");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("other attachment kinds are unchanged", () => {
|
|
120
|
+
process.env.COMB_ENABLED = "true";
|
|
121
|
+
process.env.APP_URL = "http://localhost:5274";
|
|
122
|
+
expect(
|
|
123
|
+
taskAttachmentDisplayUrl(
|
|
124
|
+
agentFsAttachment({ kind: "url", providerId: undefined, url: "https://example.com/a" }),
|
|
125
|
+
),
|
|
126
|
+
).toBe("https://example.com/a");
|
|
127
|
+
expect(
|
|
128
|
+
taskAttachmentDisplayUrl(
|
|
129
|
+
agentFsAttachment({ kind: "page", providerId: undefined, pageId: "page-1" }),
|
|
130
|
+
),
|
|
131
|
+
).toBe("http://localhost:5274/pages/page-1");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a dot segment, raw or encoded, falls back to agent-fs:<path>", () => {
|
|
135
|
+
process.env.COMB_ENABLED = "true";
|
|
136
|
+
process.env.APP_URL = "http://localhost:5274";
|
|
137
|
+
for (const path of ["../../settings", "%2e%2e/%2e%2e/settings", "a/./b.md", "a/.%2E/b.md"]) {
|
|
138
|
+
const file = { path, orgId: "org-1", driveId: "drive-1" };
|
|
139
|
+
expect(buildCombFileUrl(file)).toBeNull();
|
|
140
|
+
expect(buildAgentFsLiveUrl(file)).toBeNull();
|
|
141
|
+
expect(taskAttachmentDisplayUrl(agentFsAttachment({ path }))).toBe(`agent-fs:${path}`);
|
|
142
|
+
}
|
|
143
|
+
// Dots inside a name are not dot segments.
|
|
144
|
+
expect(buildAgentFsLiveUrl({ path: "a/.b/c..md", orgId: "org-1", driveId: "drive-1" })).toBe(
|
|
145
|
+
"https://live.example.test/file/~/org-1/drive-1/a/.b/c..md",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("agent-fs citations", () => {
|
|
151
|
+
test("store the live URL even with Comb on (the dashboard maps it when it renders)", () => {
|
|
152
|
+
process.env.COMB_ENABLED = "true";
|
|
153
|
+
process.env.APP_URL = "http://localhost:5274";
|
|
154
|
+
process.env.AGENT_FS_DEFAULT_ORG_ID = "org-1";
|
|
155
|
+
process.env.AGENT_FS_DEFAULT_DRIVE_ID = "drive-1";
|
|
156
|
+
expect(buildCitationUrl({ index: 1, kind: "agent-fs", ref: "comb-qa/notes.md" })).toBe(LIVE);
|
|
157
|
+
// Slack and prompts still link attachments to the dashboard.
|
|
158
|
+
expect(
|
|
159
|
+
taskAttachmentDisplayUrl(agentFsAttachment({ orgId: undefined, driveId: undefined })),
|
|
160
|
+
).toBe(COMB);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import * as ui from "../../apps/ui/src/lib/comb/markers";
|
|
3
|
+
import * as server from "../comb/markers";
|
|
4
|
+
|
|
5
|
+
// The server posts the "sent" reply and the dashboard reads it. Both copies
|
|
6
|
+
// of the pattern and of the "is it sent" rule must agree. The `@swarm` marker
|
|
7
|
+
// (`SWARM_MARKER_RE`) has no server copy: the server does not require it.
|
|
8
|
+
|
|
9
|
+
const TASK_ID = "0b8e1f5c-3d2a-4c6b-9e7f-1a2b3c4d5e6f";
|
|
10
|
+
const SERVICE = "svc";
|
|
11
|
+
const HUMAN = "human";
|
|
12
|
+
|
|
13
|
+
const BODIES = [
|
|
14
|
+
`${server.sentMarker(TASK_ID)} Sent to the swarm: https://app.example/tasks/${TASK_ID}`,
|
|
15
|
+
server.sentMarker(TASK_ID),
|
|
16
|
+
`ok ${server.sentMarker(TASK_ID)}`,
|
|
17
|
+
"[comb:sent task=not-a-uuid]",
|
|
18
|
+
`[comb:sent task=${TASK_ID.toUpperCase()}]`,
|
|
19
|
+
"",
|
|
20
|
+
"plain reply",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const THREADS: Array<{ author: string; replies: Array<{ author: string; body: string }> }> = [
|
|
24
|
+
{ author: HUMAN, replies: [] },
|
|
25
|
+
{ author: HUMAN, replies: [{ author: SERVICE, body: server.sentMarker(TASK_ID) }] },
|
|
26
|
+
{ author: HUMAN, replies: [{ author: HUMAN, body: server.sentMarker(TASK_ID) }] },
|
|
27
|
+
{ author: HUMAN, replies: [{ author: "other", body: server.sentMarker(TASK_ID) }] },
|
|
28
|
+
{ author: HUMAN, replies: [{ author: SERVICE, body: `ok ${server.sentMarker(TASK_ID)}` }] },
|
|
29
|
+
{
|
|
30
|
+
author: HUMAN,
|
|
31
|
+
replies: [
|
|
32
|
+
{ author: "other", body: "thanks" },
|
|
33
|
+
{ author: SERVICE, body: server.sentMarker(TASK_ID) },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
describe("sent marker: server and dashboard agree", () => {
|
|
39
|
+
test("the patterns are the same, byte for byte", () => {
|
|
40
|
+
expect(String(server.SENT_MARKER_RE)).toBe(String(ui.SENT_MARKER_RE));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the server's reply parses in the dashboard", () => {
|
|
44
|
+
expect(ui.sentTaskIdOf({ body: server.sentMarker(TASK_ID) })).toBe(TASK_ID);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("both match the same bodies", () => {
|
|
48
|
+
for (const body of BODIES) {
|
|
49
|
+
expect(server.SENT_MARKER_RE.test(body)).toBe(ui.SENT_MARKER_RE.test(body));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("both decide 'sent' the same way, with and without a known service account", () => {
|
|
54
|
+
for (const serviceUserId of [SERVICE, null]) {
|
|
55
|
+
for (const thread of THREADS) {
|
|
56
|
+
expect(server.sentTaskId(thread, thread.replies, serviceUserId) !== null).toBe(
|
|
57
|
+
ui.isSentToSwarm(thread, serviceUserId),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a known service account is the only trusted author", () => {
|
|
64
|
+
const byOther = THREADS[3] as (typeof THREADS)[number];
|
|
65
|
+
expect(server.sentTaskId(byOther, byOther.replies, SERVICE)).toBeNull();
|
|
66
|
+
expect(server.sentTaskId(byOther, byOther.replies, null)).toBe(TASK_ID);
|
|
67
|
+
});
|
|
68
|
+
});
|