@desplega.ai/agent-swarm 2.5.2 → 2.5.4
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-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
- package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
- package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
- package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
- package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
- package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
- package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
- package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
- package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
- package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
- package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
- package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
- package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
- package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
- package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
- package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
- package/dist/cli-2655k16q.js +140 -0
- package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
- package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
- package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
- package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
- package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
- package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
- package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
- package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
- package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
- package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
- package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
- package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
- package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
- package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
- package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
- package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
- package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
- package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
- package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
- package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
- package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
- package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
- package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
- package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
- package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
- package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
- package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
- package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
- package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
- package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
- package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
- package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
- package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
- package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
- package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
- package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
- package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
- package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
- package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
- package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
- package/dist/cli.js +20 -20
- package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
- package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
- package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
- package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
- package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
- package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
- package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
- package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
- package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
- package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
- package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
- package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
- package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
- package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
- package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
- package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
- package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
- package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
- package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
- package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
- package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
- package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
- package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
- package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
- package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
- package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
- package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
- package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
- package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
- package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
- package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
- package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
- package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
- package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
- package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
- package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
- package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
- package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
- package/openapi.json +3078 -2424
- package/package.json +7 -7
- package/plugin/commands/create-pr.md +2 -2
- package/plugin/pi-skills/create-pr/SKILL.md +2 -2
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/approvals.ts +48 -0
- package/src/be/db/session-tree.ts +73 -15
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +29 -2
- package/src/be/db/tasks/write.ts +15 -5
- package/src/be/db/workflow-idempotency.ts +183 -0
- package/src/be/db/workflow-runs.ts +366 -8
- package/src/be/db.ts +92 -22
- package/src/be/migrations/239_skills_fts.sql +40 -0
- package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
- package/src/be/migrations/241_skills_meta_fts.sql +39 -0
- package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
- package/src/be/sealed-json.ts +1 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
- package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/sql-connections.ts +7 -1
- package/src/be/swarm-config-guard.ts +25 -0
- package/src/be/users.ts +9 -0
- package/src/commands/controller.ts +33 -0
- package/src/commands/runner.ts +6 -2
- package/src/extensions/apply-task-create.ts +57 -11
- package/src/extensions/catalog.generated.json +4 -4
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +25 -0
- package/src/extensions/dispatcher.ts +4 -0
- package/src/extensions/post-bridge.ts +8 -0
- package/src/hooks/context-bound-note.ts +196 -0
- package/src/hooks/hook.ts +12 -0
- package/src/http/approval-requests.ts +209 -38
- package/src/http/config.ts +70 -53
- package/src/http/idempotency-key.ts +117 -0
- package/src/http/sessions.ts +27 -2
- package/src/http/workflows.ts +129 -24
- package/src/integrations/chat-sdk/inbound.ts +5 -4
- package/src/prompts/context-bound-note.ts +48 -0
- package/src/prompts/session-templates.ts +25 -1
- package/src/providers/amp-adapter.ts +30 -8
- package/src/providers/claude-adapter.ts +29 -4
- package/src/providers/claude-managed-adapter.ts +26 -0
- package/src/providers/claude-sdk-session.ts +10 -0
- package/src/providers/claude-session-events.ts +13 -0
- package/src/providers/codex-adapter.ts +12 -0
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/opencode-adapter.ts +18 -0
- package/src/providers/pi-mono-adapter.ts +35 -0
- package/src/runtime-providers/docker.ts +6 -2
- package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
- package/src/tests/amp-adapter.test.ts +97 -0
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/approval-requests.test.ts +10 -7
- package/src/tests/approval-sweeps.test.ts +12 -6
- package/src/tests/base-prompt.test.ts +1 -1
- package/src/tests/chat-sdk-inbound.test.ts +20 -0
- package/src/tests/claude-hook-output.test.ts +79 -1
- package/src/tests/claude-managed-adapter.test.ts +275 -0
- package/src/tests/codex-adapter.test.ts +163 -0
- package/src/tests/context-bound-note.test.ts +269 -0
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
- package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
- package/src/tests/extensions-pre-task.test.ts +273 -0
- package/src/tests/fetch-resolved-env.test.ts +17 -0
- package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
- package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
- package/src/tests/fixtures/fake-amp.ts +22 -8
- package/src/tests/fixtures/held-template-render.ts +42 -0
- package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/openapi-route-headers.test.ts +2 -0
- package/src/tests/opencode-adapter.test.ts +134 -0
- package/src/tests/pi-mono-adapter.test.ts +105 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/runner-push-delivery.test.ts +25 -0
- package/src/tests/runtime-controller-liveness.test.ts +133 -0
- package/src/tests/runtime-providers-docker.test.ts +2 -0
- package/src/tests/script-connections.test.ts +9 -0
- package/src/tests/script-run-config-scope.test.ts +91 -3
- package/src/tests/session-logs.test.ts +70 -0
- package/src/tests/sessions-tree.test.ts +205 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
- package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
- package/src/tests/workflow-idempotency.test.ts +915 -0
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/workflows/cancel-workflow-run.ts +1 -1
- package/src/types.ts +7 -0
- package/src/utils/context-window.ts +8 -6
- package/src/workflows/approval-cancel.ts +15 -13
- package/src/workflows/approval-notifications.ts +15 -4
- package/src/workflows/checkpoint.ts +72 -35
- package/src/workflows/engine.ts +262 -93
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/foreach.ts +2 -2
- package/src/workflows/executors/human-in-the-loop.ts +25 -1
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/foreach-driver.ts +10 -3
- package/src/workflows/foreach-join.ts +29 -6
- package/src/workflows/recovery.ts +68 -1
- package/src/workflows/resume.ts +228 -77
- package/src/workflows/retry-poller.ts +74 -7
- package/src/workflows/task-step-routing.ts +29 -7
- package/templates/ai-toolbox.manifest.json +8 -8
- package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
- package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
- package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
- package/templates/extensions/comms-adherence-gate/README.md +38 -13
- package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
- package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
- package/templates/skills/agent-browser/SKILL.md +4 -4
- package/templates/skills/agent-browser/content.md +4 -4
- package/templates/skills/code-quality/config.json +1 -1
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/delegate-work/content.md +29 -25
- package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
- package/templates/skills/qa/content.md +2 -2
- package/templates/skills/researching/content.md +2 -0
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { CHILD_PROCESS_TEST_BUDGET_MS, expectChildOk, runChild } from "./test-proc";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Regression test for the GitLab git credential helper setup in
|
|
9
|
+
* docker-entrypoint.sh.
|
|
10
|
+
*
|
|
11
|
+
* The GITLAB_HOST assignment and the `git config --global ... helper` command
|
|
12
|
+
* are extracted verbatim from docker-entrypoint.sh and run under bash against
|
|
13
|
+
* real `git`, so this tracks the deployed snippet instead of a copy of it.
|
|
14
|
+
*
|
|
15
|
+
* Regression: the helper was set with a plain `git config --global KEY VALUE`.
|
|
16
|
+
* When the origin already had several helper values (a startup hook's
|
|
17
|
+
* `helper = ""` reset plus its own helper, or a second boot on a persisted
|
|
18
|
+
* HOME), git refused with "cannot overwrite multiple values with a single
|
|
19
|
+
* value" and `set -e` killed the entrypoint. `--replace-all` makes the step
|
|
20
|
+
* idempotent for that one origin.
|
|
21
|
+
*
|
|
22
|
+
* Every git call runs with an isolated HOME / global config, no system config,
|
|
23
|
+
* terminal prompts off, and synthetic hosts and tokens. Nothing hits the network.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const entrypointPath = `${import.meta.dir}/../../docker-entrypoint.sh`;
|
|
27
|
+
|
|
28
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: a literal shell expansion in the entrypoint
|
|
29
|
+
const HELPER_KEY_EXPR = '"credential.${GITLAB_HOST%/}.helper"';
|
|
30
|
+
const SYNTHETIC_TOKEN = "glpat-synthetic-test-token"; // gitleaks:allow (synthetic test fixture)
|
|
31
|
+
const HOOK_HELPER = "!synthetic-hook-helper";
|
|
32
|
+
|
|
33
|
+
function extractGitlabHelperSnippet(): string {
|
|
34
|
+
const script = readFileSync(entrypointPath, "utf8");
|
|
35
|
+
|
|
36
|
+
const hostLineStart = script.indexOf('GITLAB_HOST="${GITLAB_URL:-');
|
|
37
|
+
if (hostLineStart === -1) {
|
|
38
|
+
throw new Error("Could not locate the GITLAB_HOST assignment in docker-entrypoint.sh.");
|
|
39
|
+
}
|
|
40
|
+
const hostLine = script.slice(hostLineStart, script.indexOf("\n", hostLineStart));
|
|
41
|
+
|
|
42
|
+
const keyIndex = script.indexOf(HELPER_KEY_EXPR, hostLineStart);
|
|
43
|
+
if (keyIndex === -1) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Could not locate ${HELPER_KEY_EXPR} in the GitLab block of docker-entrypoint.sh.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const commandStart = script.lastIndexOf("git config --global", keyIndex);
|
|
49
|
+
const closingMarker = "; f'";
|
|
50
|
+
const closingIndex = script.indexOf(closingMarker, keyIndex);
|
|
51
|
+
if (commandStart === -1 || closingIndex === -1) {
|
|
52
|
+
throw new Error("Could not locate the bounds of the GitLab helper `git config` command.");
|
|
53
|
+
}
|
|
54
|
+
const command = script.slice(commandStart, closingIndex + closingMarker.length);
|
|
55
|
+
|
|
56
|
+
return `set -e\n${hostLine}\n${command}\n`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const snippet = extractGitlabHelperSnippet();
|
|
60
|
+
const tempDirs: string[] = [];
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function makeHome(): string {
|
|
67
|
+
const home = mkdtempSync(join(tmpdir(), "gitlab-helper-"));
|
|
68
|
+
tempDirs.push(home);
|
|
69
|
+
return home;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isolatedEnv(home: string, extra: Record<string, string> = {}): Record<string, string> {
|
|
73
|
+
return {
|
|
74
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
75
|
+
HOME: home,
|
|
76
|
+
XDG_CONFIG_HOME: join(home, ".config"),
|
|
77
|
+
GIT_CONFIG_GLOBAL: join(home, ".gitconfig"),
|
|
78
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
79
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
80
|
+
LANG: "C",
|
|
81
|
+
LC_ALL: "C",
|
|
82
|
+
...extra,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function git(home: string, args: string[], stdin?: string, extra?: Record<string, string>) {
|
|
87
|
+
return runChild(["git", ...args], { env: isolatedEnv(home, extra), stdin });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function addHelper(home: string, origin: string, value: string) {
|
|
91
|
+
expectChildOk(
|
|
92
|
+
await git(home, ["config", "--global", "--add", `credential.${origin}.helper`, value]),
|
|
93
|
+
"git config --add",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The startup-hook pattern: reset inherited helpers, then add its own. */
|
|
98
|
+
async function addHookPair(home: string, origin: string) {
|
|
99
|
+
await addHelper(home, origin, "");
|
|
100
|
+
await addHelper(home, origin, HOOK_HELPER);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function getHelpers(home: string, origin: string): Promise<string[]> {
|
|
104
|
+
const result = await git(home, [
|
|
105
|
+
"config",
|
|
106
|
+
"--global",
|
|
107
|
+
"--get-all",
|
|
108
|
+
`credential.${origin}.helper`,
|
|
109
|
+
]);
|
|
110
|
+
if (result.exitCode === 1) return [];
|
|
111
|
+
expectChildOk(result, "git config --get-all");
|
|
112
|
+
return result.stdout.replace(/\n$/, "").split("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runSnippet(home: string, gitlabUrl?: string) {
|
|
116
|
+
const extra: Record<string, string> = { GITLAB_TOKEN: SYNTHETIC_TOKEN };
|
|
117
|
+
if (gitlabUrl !== undefined) extra.GITLAB_URL = gitlabUrl;
|
|
118
|
+
return runChild(["bash", "-c", snippet], { env: isolatedEnv(home, extra) });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function expectOnlyEntrypointHelper(helpers: string[]) {
|
|
122
|
+
expect(helpers).toHaveLength(1);
|
|
123
|
+
expect(helpers[0]).toStartWith("!f() {");
|
|
124
|
+
expect(helpers[0]).toContain("username=oauth2");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const ORIGIN = "https://gitlab.example.test";
|
|
128
|
+
|
|
129
|
+
describe("docker-entrypoint.sh: GitLab git credential helper", () => {
|
|
130
|
+
test(
|
|
131
|
+
"sets the helper when none exists (default gitlab.com origin)",
|
|
132
|
+
async () => {
|
|
133
|
+
const home = makeHome();
|
|
134
|
+
expectChildOk(await runSnippet(home), "snippet");
|
|
135
|
+
expectOnlyEntrypointHelper(await getHelpers(home, "https://gitlab.com"));
|
|
136
|
+
},
|
|
137
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
test(
|
|
141
|
+
"replaces a single existing value",
|
|
142
|
+
async () => {
|
|
143
|
+
const home = makeHome();
|
|
144
|
+
await addHelper(home, ORIGIN, "!old-helper");
|
|
145
|
+
expectChildOk(await runSnippet(home, ORIGIN), "snippet");
|
|
146
|
+
expectOnlyEntrypointHelper(await getHelpers(home, ORIGIN));
|
|
147
|
+
},
|
|
148
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
test(
|
|
152
|
+
"succeeds over an empty reset value plus a custom helper",
|
|
153
|
+
async () => {
|
|
154
|
+
const home = makeHome();
|
|
155
|
+
await addHookPair(home, ORIGIN);
|
|
156
|
+
expectChildOk(await runSnippet(home, ORIGIN), "snippet");
|
|
157
|
+
expectOnlyEntrypointHelper(await getHelpers(home, ORIGIN));
|
|
158
|
+
},
|
|
159
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
test(
|
|
163
|
+
"succeeds over several non-empty values",
|
|
164
|
+
async () => {
|
|
165
|
+
const home = makeHome();
|
|
166
|
+
await addHelper(home, ORIGIN, "!helper-one");
|
|
167
|
+
await addHelper(home, ORIGIN, "!helper-two");
|
|
168
|
+
expectChildOk(await runSnippet(home, ORIGIN), "snippet");
|
|
169
|
+
expectOnlyEntrypointHelper(await getHelpers(home, ORIGIN));
|
|
170
|
+
},
|
|
171
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
test(
|
|
175
|
+
"repeated runs do not accumulate values",
|
|
176
|
+
async () => {
|
|
177
|
+
const home = makeHome();
|
|
178
|
+
for (let i = 0; i < 3; i++) expectChildOk(await runSnippet(home, ORIGIN), `snippet run ${i}`);
|
|
179
|
+
expectOnlyEntrypointHelper(await getHelpers(home, ORIGIN));
|
|
180
|
+
},
|
|
181
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
test(
|
|
185
|
+
"leaves another origin's helpers untouched",
|
|
186
|
+
async () => {
|
|
187
|
+
const home = makeHome();
|
|
188
|
+
const other = "https://other.example.test";
|
|
189
|
+
await addHookPair(home, other);
|
|
190
|
+
await addHookPair(home, ORIGIN);
|
|
191
|
+
expectChildOk(await runSnippet(home, ORIGIN), "snippet");
|
|
192
|
+
expect(await getHelpers(home, other)).toEqual(["", HOOK_HELPER]);
|
|
193
|
+
expectOnlyEntrypointHelper(await getHelpers(home, ORIGIN));
|
|
194
|
+
},
|
|
195
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
test(
|
|
199
|
+
"keeps the port and strips one trailing slash from GITLAB_URL",
|
|
200
|
+
async () => {
|
|
201
|
+
const home = makeHome();
|
|
202
|
+
const origin = "https://gitlab.example.test:8443";
|
|
203
|
+
await addHookPair(home, origin);
|
|
204
|
+
expectChildOk(await runSnippet(home, `${origin}/`), "snippet");
|
|
205
|
+
expectOnlyEntrypointHelper(await getHelpers(home, origin));
|
|
206
|
+
expect(await getHelpers(home, `${origin}/`)).toEqual([]);
|
|
207
|
+
},
|
|
208
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
test(
|
|
212
|
+
"stores a reference to GITLAB_TOKEN, never the token value",
|
|
213
|
+
async () => {
|
|
214
|
+
const home = makeHome();
|
|
215
|
+
expectChildOk(await runSnippet(home, ORIGIN), "snippet");
|
|
216
|
+
const gitconfig = readFileSync(join(home, ".gitconfig"), "utf8");
|
|
217
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: the helper must keep the shell reference
|
|
218
|
+
expect(gitconfig).toContain("${GITLAB_TOKEN}");
|
|
219
|
+
expect(gitconfig).not.toContain(SYNTHETIC_TOKEN);
|
|
220
|
+
},
|
|
221
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
test(
|
|
225
|
+
"answers get from the environment and ignores store / erase",
|
|
226
|
+
async () => {
|
|
227
|
+
const home = makeHome();
|
|
228
|
+
expectChildOk(await runSnippet(home, ORIGIN), "snippet");
|
|
229
|
+
const tokenEnv = { GITLAB_TOKEN: SYNTHETIC_TOKEN };
|
|
230
|
+
|
|
231
|
+
const fill = expectChildOk(
|
|
232
|
+
await git(
|
|
233
|
+
home,
|
|
234
|
+
["credential", "fill"],
|
|
235
|
+
"protocol=https\nhost=gitlab.example.test\n\n",
|
|
236
|
+
tokenEnv,
|
|
237
|
+
),
|
|
238
|
+
"git credential fill",
|
|
239
|
+
);
|
|
240
|
+
expect(fill.stdout).toContain("username=oauth2\n");
|
|
241
|
+
expect(fill.stdout).toContain(`password=${SYNTHETIC_TOKEN}\n`);
|
|
242
|
+
|
|
243
|
+
const cred = `protocol=https\nhost=gitlab.example.test\nusername=oauth2\npassword=${SYNTHETIC_TOKEN}\n\n`;
|
|
244
|
+
for (const action of ["approve", "reject"]) {
|
|
245
|
+
const result = expectChildOk(
|
|
246
|
+
await git(home, ["credential", action], cred, tokenEnv),
|
|
247
|
+
`git credential ${action}`,
|
|
248
|
+
);
|
|
249
|
+
expect(result.stdout).toBe("");
|
|
250
|
+
}
|
|
251
|
+
expect(readFileSync(join(home, ".gitconfig"), "utf8")).not.toContain(SYNTHETIC_TOKEN);
|
|
252
|
+
},
|
|
253
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
test(
|
|
257
|
+
"a startup hook can re-add its reset + helper pair across restarts",
|
|
258
|
+
async () => {
|
|
259
|
+
const home = makeHome();
|
|
260
|
+
for (let boot = 0; boot < 2; boot++) {
|
|
261
|
+
expectChildOk(await runSnippet(home, ORIGIN), `snippet boot ${boot}`);
|
|
262
|
+
await addHookPair(home, ORIGIN);
|
|
263
|
+
// The hook's "" resets the entrypoint helper before it in the chain,
|
|
264
|
+
// and the next boot's --replace-all collapses all three again.
|
|
265
|
+
const helpers = await getHelpers(home, ORIGIN);
|
|
266
|
+
expect(helpers).toHaveLength(3);
|
|
267
|
+
expectOnlyEntrypointHelper(helpers.slice(0, 1));
|
|
268
|
+
expect(helpers.slice(1)).toEqual(["", HOOK_HELPER]);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
272
|
+
);
|
|
273
|
+
});
|
|
@@ -83,6 +83,8 @@ const makeCtx = (
|
|
|
83
83
|
dbThrows?: boolean;
|
|
84
84
|
/** The caller's agents.isLead; null means no agents row. */
|
|
85
85
|
callerIsLead?: boolean | null;
|
|
86
|
+
/** user_external_ids x users rows for the mention lookup. */
|
|
87
|
+
addressees?: Record<string, unknown>[];
|
|
86
88
|
} = {},
|
|
87
89
|
) => {
|
|
88
90
|
const kv: Record<string, unknown> = {};
|
|
@@ -99,6 +101,10 @@ const makeCtx = (
|
|
|
99
101
|
db_query: async (args: any) => {
|
|
100
102
|
queries.push(args);
|
|
101
103
|
if (opts.dbThrows) throw new Error("db down");
|
|
104
|
+
if (String(args.sql).includes("user_external_ids")) {
|
|
105
|
+
const rows = (opts.addressees ?? []).filter((r) => args.params.includes(r.externalId));
|
|
106
|
+
return { success: true, data: { rows } };
|
|
107
|
+
}
|
|
102
108
|
if (String(args.sql).includes("FROM agents")) {
|
|
103
109
|
const lead = opts.callerIsLead ?? false;
|
|
104
110
|
return {
|
|
@@ -157,6 +163,8 @@ describe("defaults", () => {
|
|
|
157
163
|
timeoutMs: 4000,
|
|
158
164
|
maxBlocksPerTask: 1,
|
|
159
165
|
provider: "auto",
|
|
166
|
+
plainMode: "shadow",
|
|
167
|
+
plainThreshold: 0.25,
|
|
160
168
|
});
|
|
161
169
|
});
|
|
162
170
|
|
|
@@ -198,11 +206,17 @@ describe("pass", () => {
|
|
|
198
206
|
const call = jevCalls[0]!;
|
|
199
207
|
expect(call.url).toBe("https://api.typesafe.ai/v1/systemone");
|
|
200
208
|
expect(call.auth).toBe("Bearer tsk-test");
|
|
201
|
-
expect(Object.keys(call.body.questions).sort()).toEqual([
|
|
209
|
+
expect(Object.keys(call.body.questions).sort()).toEqual([
|
|
210
|
+
"language",
|
|
211
|
+
"plain",
|
|
212
|
+
"tone",
|
|
213
|
+
"verbosity",
|
|
214
|
+
]);
|
|
215
|
+
expect(call.body.state.plain_language_rules).toEqual(mod.PLAIN_RULES);
|
|
202
216
|
expect(call.body.state.latest_request).toBe("which option did you pick?");
|
|
203
217
|
});
|
|
204
218
|
|
|
205
|
-
test("non-Slack tasks, requesters without prefs, non-completions and other agents' tasks skip Jev", async () => {
|
|
219
|
+
test("with plain off: non-Slack tasks, requesters without prefs, non-completions and other agents' tasks skip Jev", async () => {
|
|
206
220
|
jevScores(0.1);
|
|
207
221
|
const cases: Array<[any, Row]> = [
|
|
208
222
|
[completion("x"), taskRow({ source: "mcp", slackThreadTs: null })],
|
|
@@ -220,7 +234,7 @@ describe("pass", () => {
|
|
|
220
234
|
[completion("x", { taskId: "not-a-uuid" }), taskRow()],
|
|
221
235
|
];
|
|
222
236
|
for (const [event, row] of cases) {
|
|
223
|
-
const { ctx } = makeCtx({ mode: "enforce" }, { row });
|
|
237
|
+
const { ctx } = makeCtx({ mode: "enforce", plainMode: "off" }, { row });
|
|
224
238
|
expect(await handler(event, ctx)).toBeUndefined();
|
|
225
239
|
}
|
|
226
240
|
expect(jevCalls).toHaveLength(0);
|
|
@@ -264,9 +278,9 @@ describe("pass", () => {
|
|
|
264
278
|
expect(queries).toHaveLength(1);
|
|
265
279
|
});
|
|
266
280
|
|
|
267
|
-
test("mode off never
|
|
281
|
+
test("mode off and plainMode off never read the task", async () => {
|
|
268
282
|
jevScores(0.1);
|
|
269
|
-
const { ctx, queries } = makeCtx({ mode: "off" });
|
|
283
|
+
const { ctx, queries } = makeCtx({ mode: "off", plainMode: "off" });
|
|
270
284
|
expect(await handler(completion("x"), ctx)).toBeUndefined();
|
|
271
285
|
expect(queries).toHaveLength(0);
|
|
272
286
|
});
|
|
@@ -387,13 +401,12 @@ describe("fail open", () => {
|
|
|
387
401
|
|
|
388
402
|
describe("self-test", () => {
|
|
389
403
|
test("records both controls without judging real traffic", async () => {
|
|
390
|
-
mockJev((body) =>
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
);
|
|
404
|
+
mockJev((body) => {
|
|
405
|
+
const msg = String(body.state.agent_message);
|
|
406
|
+
const bad = msg.startsWith("Great") || msg.startsWith("Prompt cache monitor");
|
|
407
|
+
const [dim] = Object.keys(body.questions);
|
|
408
|
+
return Response.json({ answers: { [dim!]: { noul: bad ? 0.1 : 0.9 } } });
|
|
409
|
+
});
|
|
397
410
|
const { ctx, kv, queries, counters } = makeCtx();
|
|
398
411
|
const event = {
|
|
399
412
|
tool: "store-progress",
|
|
@@ -405,9 +418,305 @@ describe("self-test", () => {
|
|
|
405
418
|
positiveControlBadScore: 0.1,
|
|
406
419
|
negativeControlGoodScore: 0.9,
|
|
407
420
|
controlsOk: true,
|
|
421
|
+
plainPositiveControlBadScore: 0.1,
|
|
422
|
+
plainNegativeControlGoodScore: 0.9,
|
|
423
|
+
plainControlsOk: true,
|
|
408
424
|
provider: "typesafe",
|
|
409
425
|
});
|
|
426
|
+
// Two verbosity calls and two plain calls, each asking one question.
|
|
427
|
+
expect(jevCalls.map((c) => Object.keys(c.body.questions)[0]).sort()).toEqual([
|
|
428
|
+
"plain",
|
|
429
|
+
"plain",
|
|
430
|
+
"verbosity",
|
|
431
|
+
"verbosity",
|
|
432
|
+
]);
|
|
410
433
|
expect(queries).toHaveLength(0);
|
|
411
434
|
expect(counters.total).toBeUndefined();
|
|
412
435
|
});
|
|
413
436
|
});
|
|
437
|
+
|
|
438
|
+
describe("plain", () => {
|
|
439
|
+
const slackReply = (message: string, extra: Record<string, unknown> = {}) => ({
|
|
440
|
+
tool: "slack-reply",
|
|
441
|
+
args: { taskId: TASK_ID, message, ...extra },
|
|
442
|
+
requestInfo: { callOrigin: "mcp", agentId: "agent-1" },
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("a requester without prefs still gets the plain question", async () => {
|
|
446
|
+
jevScores(0.9);
|
|
447
|
+
const { ctx, counters } = makeCtx({}, { row: taskRow({ comms: null }) });
|
|
448
|
+
expect(await handler(completion("Picked B."), ctx)).toBeUndefined();
|
|
449
|
+
expect(Object.keys(jevCalls[0]!.body.questions)).toEqual(["plain"]);
|
|
450
|
+
expect(counters).toMatchObject({ total: 1, pass: 1 });
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("mode off with plainMode shadow asks only plain", async () => {
|
|
454
|
+
jevScores(0.9);
|
|
455
|
+
const { ctx } = makeCtx({ mode: "off" });
|
|
456
|
+
await handler(completion("Picked B."), ctx);
|
|
457
|
+
expect(Object.keys(jevCalls[0]!.body.questions)).toEqual(["plain"]);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("plain uses plainThreshold, not threshold", async () => {
|
|
461
|
+
jevScores({ plain: 0.3 });
|
|
462
|
+
const passing = makeCtx({ mode: "enforce", plainMode: "enforce", threshold: 0.75 });
|
|
463
|
+
expect(await handler(completion("Picked B."), passing.ctx)).toBeUndefined();
|
|
464
|
+
expect(passing.counters).toMatchObject({ pass: 1 });
|
|
465
|
+
|
|
466
|
+
jevScores({ plain: 0.2 });
|
|
467
|
+
const failing = makeCtx({ mode: "enforce", plainMode: "enforce", threshold: 0.75 });
|
|
468
|
+
const result = await handler(completion("p50 Δ +120ms (n=37)"), failing.ctx);
|
|
469
|
+
expect(result).toMatchObject({ action: "block" });
|
|
470
|
+
expect(result.reason).toContain("plain (0.20 vs 0.25)");
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("plainMode shadow records a plain failure and never blocks, even with mode enforce", async () => {
|
|
474
|
+
jevScores({ plain: 0.05 });
|
|
475
|
+
const { ctx, counters, kv } = makeCtx({ mode: "enforce" });
|
|
476
|
+
expect(await handler(completion("p50 Δ +120ms (n=37)"), ctx)).toBeUndefined();
|
|
477
|
+
expect(counters).toMatchObject({ fail: 1, shadowed: 1, "dim:plain": 1 });
|
|
478
|
+
expect(counters.blocked).toBeUndefined();
|
|
479
|
+
expect(kv.last).toMatchObject({ failed: ["plain"], plainMode: "shadow" });
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("plainMode enforce blocks and points the agent at the comms skill", async () => {
|
|
483
|
+
jevScores({ plain: 0.05 });
|
|
484
|
+
const { ctx, counters } = makeCtx({ mode: "shadow", plainMode: "enforce" });
|
|
485
|
+
const result = await handler(completion("p50 Δ +120ms (n=37)"), ctx);
|
|
486
|
+
expect(result).toMatchObject({ action: "block" });
|
|
487
|
+
expect(result.reason).toContain("plain (0.05 vs 0.25)");
|
|
488
|
+
expect(result.reason).toContain("`comms` skill (Simpler mode)");
|
|
489
|
+
expect(result.reason).toContain("Keep every number, name, link and hedge verbatim");
|
|
490
|
+
expect(result.reason).toContain("call store-progress again");
|
|
491
|
+
expect(counters).toMatchObject({ blocked: 1, "dim:plain": 1 });
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("a failing preference in shadow is left out of a plain block", async () => {
|
|
495
|
+
jevScores({ plain: 0.05, verbosity: 0.1 });
|
|
496
|
+
const { ctx } = makeCtx({ mode: "shadow", plainMode: "enforce" });
|
|
497
|
+
const result = await handler(completion("x"), ctx);
|
|
498
|
+
expect(result.reason).toContain("plain (");
|
|
499
|
+
expect(result.reason).not.toContain("verbosity (");
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("structured JSON output skips the plain question", async () => {
|
|
503
|
+
jevScores(0.9);
|
|
504
|
+
const { ctx } = makeCtx({ mode: "enforce", plainMode: "enforce" });
|
|
505
|
+
await handler(completion('{"prUrl":"https://example.com/pull/1","n":3}'), ctx);
|
|
506
|
+
expect(Object.keys(jevCalls[0]!.body.questions)).not.toContain("plain");
|
|
507
|
+
expect(mod.looksLikeJson("{not json")).toBe(false);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("a JSON message on any Slack tool is still judged plain, even with mode off", async () => {
|
|
511
|
+
const json = '{"p50":"+120ms","n":37}';
|
|
512
|
+
for (const tool of ["slack-reply", "slack-post", "slack-start-thread", "slack-update"]) {
|
|
513
|
+
jevCalls = [];
|
|
514
|
+
jevScores({ plain: 0.05 });
|
|
515
|
+
const { ctx } = makeCtx({ mode: "off", plainMode: "enforce" });
|
|
516
|
+
const result = await handler(
|
|
517
|
+
{
|
|
518
|
+
tool,
|
|
519
|
+
args: { taskId: TASK_ID, channelId: "C1", message: json },
|
|
520
|
+
requestInfo: { callOrigin: "mcp", agentId: "agent-1", sourceTaskId: TASK_ID },
|
|
521
|
+
},
|
|
522
|
+
ctx,
|
|
523
|
+
);
|
|
524
|
+
expect(jevCalls).toHaveLength(1);
|
|
525
|
+
expect(Object.keys(jevCalls[0]!.body.questions)).toEqual(["plain"]);
|
|
526
|
+
expect(result).toMatchObject({ action: "block" });
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("slack-reply is judged on its message with the named task", async () => {
|
|
531
|
+
jevScores({ plain: 0.05 });
|
|
532
|
+
const { ctx, queries } = makeCtx({ plainMode: "enforce" });
|
|
533
|
+
const result = await handler(slackReply("p50 Δ +120ms (n=37)"), ctx);
|
|
534
|
+
expect(result).toMatchObject({ action: "block" });
|
|
535
|
+
expect(result.reason).toContain("call slack-reply again");
|
|
536
|
+
expect(queries[0].params).toEqual([TASK_ID]);
|
|
537
|
+
expect(jevCalls[0]!.body.state.agent_message).toBe("p50 Δ +120ms (n=37)");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("slack-post, slack-start-thread and slack-update use the caller's task context", async () => {
|
|
541
|
+
for (const tool of ["slack-post", "slack-start-thread", "slack-update"]) {
|
|
542
|
+
jevCalls = [];
|
|
543
|
+
jevScores(0.9);
|
|
544
|
+
const { ctx, queries } = makeCtx();
|
|
545
|
+
await handler(
|
|
546
|
+
{
|
|
547
|
+
tool,
|
|
548
|
+
args: { channelId: "C1", message: "Deploys take 4 minutes now." },
|
|
549
|
+
requestInfo: { callOrigin: "mcp", agentId: "agent-1", sourceTaskId: TASK_ID },
|
|
550
|
+
},
|
|
551
|
+
ctx,
|
|
552
|
+
);
|
|
553
|
+
expect(queries[0].params).toEqual([TASK_ID]);
|
|
554
|
+
expect(jevCalls).toHaveLength(1);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("a Slack post from a non-Slack task is still judged", async () => {
|
|
559
|
+
jevScores(0.9);
|
|
560
|
+
const { ctx } = makeCtx({}, { row: taskRow({ source: "mcp", slackThreadTs: null }) });
|
|
561
|
+
await handler(slackReply("Deploys take 4 minutes now."), ctx);
|
|
562
|
+
expect(jevCalls).toHaveLength(1);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("a Slack post without a task, or on another agent's task, skips Jev", async () => {
|
|
566
|
+
jevScores(0.05);
|
|
567
|
+
const noTask = makeCtx({ plainMode: "enforce" });
|
|
568
|
+
expect(
|
|
569
|
+
await handler(
|
|
570
|
+
{
|
|
571
|
+
tool: "slack-post",
|
|
572
|
+
args: { channelId: "C1", message: "x" },
|
|
573
|
+
requestInfo: { callOrigin: "mcp", agentId: "agent-1" },
|
|
574
|
+
},
|
|
575
|
+
noTask.ctx,
|
|
576
|
+
),
|
|
577
|
+
).toBeUndefined();
|
|
578
|
+
const other = makeCtx({ plainMode: "enforce" }, { row: taskRow({ agentId: "agent-2" }) });
|
|
579
|
+
expect(await handler(slackReply("x"), other.ctx)).toBeUndefined();
|
|
580
|
+
expect(jevCalls).toHaveLength(0);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("a Slack post on an unassigned task the caller did not create skips Jev", async () => {
|
|
584
|
+
// slack-reply itself rejects this task, so nothing from it may reach the judge,
|
|
585
|
+
// even though store-progress may write to an unassigned task.
|
|
586
|
+
jevScores(0.05);
|
|
587
|
+
const unassigned = makeCtx({ plainMode: "enforce" }, { row: taskRow({ agentId: null }) });
|
|
588
|
+
expect(await handler(slackReply("x"), unassigned.ctx)).toBeUndefined();
|
|
589
|
+
const lead = makeCtx(
|
|
590
|
+
{ plainMode: "enforce" },
|
|
591
|
+
{ row: taskRow({ agentId: "agent-2" }), callerIsLead: true },
|
|
592
|
+
);
|
|
593
|
+
expect(await handler(slackReply("x"), lead.ctx)).toBeUndefined();
|
|
594
|
+
expect(jevCalls).toHaveLength(0);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("the task's creator may post and is judged", async () => {
|
|
598
|
+
jevScores(0.9);
|
|
599
|
+
const { ctx } = makeCtx(
|
|
600
|
+
{},
|
|
601
|
+
{ row: taskRow({ agentId: "agent-2", creatorAgentId: "agent-1" }) },
|
|
602
|
+
);
|
|
603
|
+
await handler(slackReply("Deploys take 4 minutes now."), ctx);
|
|
604
|
+
expect(jevCalls).toHaveLength(1);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("store-progress and slack-reply share one block budget per task", async () => {
|
|
608
|
+
jevScores({ plain: 0.05 });
|
|
609
|
+
const { ctx, counters } = makeCtx({ plainMode: "enforce" });
|
|
610
|
+
expect(await handler(slackReply("p50 Δ"), ctx)).toMatchObject({ action: "block" });
|
|
611
|
+
expect(await handler(completion("p50 Δ"), ctx)).toBeUndefined();
|
|
612
|
+
expect(counters).toMatchObject({ blocked: 1, escaped: 1 });
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("a script SDK call is recorded and never blocked", async () => {
|
|
616
|
+
jevScores({ plain: 0.05 });
|
|
617
|
+
const { ctx, counters } = makeCtx({ mode: "enforce", plainMode: "enforce" });
|
|
618
|
+
const event = {
|
|
619
|
+
...slackReply("p50 Δ"),
|
|
620
|
+
requestInfo: { callOrigin: "script-sdk", agentId: "agent-1" },
|
|
621
|
+
};
|
|
622
|
+
expect(await handler(event, ctx)).toBeUndefined();
|
|
623
|
+
expect(counters).toMatchObject({ fail: 1, shadowed: 1 });
|
|
624
|
+
expect(counters.blocked).toBeUndefined();
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
describe("multi-person threads", () => {
|
|
629
|
+
const BOB_PREFS = { language: "Spanish", tone: "warm" };
|
|
630
|
+
const bob = { externalId: "UBOB", id: "user-2", name: "Bob", comms: JSON.stringify(BOB_PREFS) };
|
|
631
|
+
const post = (message: string) => ({
|
|
632
|
+
tool: "slack-reply",
|
|
633
|
+
args: { taskId: TASK_ID, message },
|
|
634
|
+
requestInfo: { callOrigin: "mcp", agentId: "agent-1" },
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("mentionedSlackIds keeps first-appearance order and drops duplicates", () => {
|
|
638
|
+
expect(mod.mentionedSlackIds("<@UBOB|bob> and <@U1> then <@UBOB>")).toEqual(["UBOB", "U1"]);
|
|
639
|
+
expect(mod.mentionedSlackIds("no mentions, @bob")).toEqual([]);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("a message that mentions another person is judged against that person's prefs", async () => {
|
|
643
|
+
jevScores({ language: 0.1 });
|
|
644
|
+
const { ctx, kv } = makeCtx({ mode: "enforce" }, { addressees: [bob] });
|
|
645
|
+
const result = await handler(post("<@UBOB> hola, el deploy tarda 4 minutos."), ctx);
|
|
646
|
+
const state = jevCalls[0]!.body.state;
|
|
647
|
+
expect(state.requester).toBe("Bob");
|
|
648
|
+
expect(state.requester_preferences).toEqual(BOB_PREFS);
|
|
649
|
+
expect(Object.keys(jevCalls[0]!.body.questions).sort()).toEqual(["language", "plain", "tone"]);
|
|
650
|
+
expect(result).toMatchObject({ action: "block" });
|
|
651
|
+
expect(result.reason).toContain("Bob's stated preferences");
|
|
652
|
+
expect(kv.last).toMatchObject({
|
|
653
|
+
judgedFor: "mention",
|
|
654
|
+
audienceId: "user-2",
|
|
655
|
+
requester: "Alice",
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("the first mentioned person with prefs wins; one without prefs is skipped", async () => {
|
|
660
|
+
jevScores(0.9);
|
|
661
|
+
const carol = { externalId: "UCAROL", id: "user-3", name: "Carol", comms: null };
|
|
662
|
+
const { ctx } = makeCtx({}, { addressees: [carol, bob] });
|
|
663
|
+
await handler(post("<@UCAROL> <@UBOB> deploys take 4 minutes."), ctx);
|
|
664
|
+
expect(jevCalls[0]!.body.state.requester).toBe("Bob");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("no mention, or an unknown one, falls back to the requester", async () => {
|
|
668
|
+
for (const message of ["Deploys take 4 minutes.", "<@UNOBODY> deploys take 4 minutes."]) {
|
|
669
|
+
jevCalls = [];
|
|
670
|
+
jevScores(0.9);
|
|
671
|
+
const { ctx, kv } = makeCtx({}, { addressees: [bob] });
|
|
672
|
+
await handler(post(message), ctx);
|
|
673
|
+
expect(jevCalls[0]!.body.state.requester_preferences).toEqual(PREFS);
|
|
674
|
+
expect(kv.last).toMatchObject({ judgedFor: "requester", audience: "Alice" });
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test("mentioning the requester keeps the requester's prefs", async () => {
|
|
679
|
+
jevScores(0.9);
|
|
680
|
+
const alice = {
|
|
681
|
+
externalId: "U1",
|
|
682
|
+
id: "user-1",
|
|
683
|
+
name: "Alice",
|
|
684
|
+
comms: JSON.stringify(BOB_PREFS),
|
|
685
|
+
};
|
|
686
|
+
const { ctx } = makeCtx({}, { addressees: [alice] });
|
|
687
|
+
await handler(post("<@U1> deploys take 4 minutes."), ctx);
|
|
688
|
+
expect(jevCalls[0]!.body.state.requester_preferences).toEqual(PREFS);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test.each([
|
|
692
|
+
null,
|
|
693
|
+
"{}",
|
|
694
|
+
])("a self-mention without requester prefs (%s) skips to the next mentioned person with prefs", async (comms) => {
|
|
695
|
+
jevScores(0.9);
|
|
696
|
+
const alice = { externalId: "U1", id: "user-1", name: "Alice", comms };
|
|
697
|
+
const { ctx, kv } = makeCtx(
|
|
698
|
+
{ mode: "enforce", plainMode: "off" },
|
|
699
|
+
{ row: taskRow({ comms }), addressees: [alice, bob] },
|
|
700
|
+
);
|
|
701
|
+
await handler(post("<@U1> <@UBOB> deploys take 4 minutes."), ctx);
|
|
702
|
+
expect(jevCalls).toHaveLength(1);
|
|
703
|
+
expect(jevCalls[0]!.body.state.requester).toBe("Bob");
|
|
704
|
+
expect(jevCalls[0]!.body.state.requester_preferences).toEqual(BOB_PREFS);
|
|
705
|
+
expect(kv.last).toMatchObject({ judgedFor: "mention", audienceId: "user-2" });
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("a completion that mentions someone is judged for them too", async () => {
|
|
709
|
+
jevScores(0.9);
|
|
710
|
+
const { ctx } = makeCtx({}, { addressees: [bob] });
|
|
711
|
+
await handler(completion("<@UBOB> done, PR #12."), ctx);
|
|
712
|
+
expect(jevCalls[0]!.body.state.requester).toBe("Bob");
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("with mode off, plain alone skips the mention lookup", async () => {
|
|
716
|
+
jevScores(0.9);
|
|
717
|
+
const { ctx, queries } = makeCtx({ mode: "off" }, { addressees: [bob] });
|
|
718
|
+
await handler(post("<@UBOB> deploys take 4 minutes."), ctx);
|
|
719
|
+
expect(queries.some((q) => String(q.sql).includes("user_external_ids"))).toBe(false);
|
|
720
|
+
expect(jevCalls).toHaveLength(1);
|
|
721
|
+
});
|
|
722
|
+
});
|