@desplega.ai/agent-swarm 2.2.0 → 2.4.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 +1 -0
- package/dist/{acp-adapter-k0vwfyvn.js → acp-adapter-kedstxx6.js} +6 -6
- package/dist/{actions-w4pnb8vz.js → actions-vget6r1k.js} +18 -16
- package/dist/{amp-adapter-0cmxb2t5.js → amp-adapter-xeb5mktv.js} +2 -2
- package/dist/{app-zneem7g9.js → app-mqap8t8m.js} +3 -3
- package/dist/{assistant-wbzd2rnr.js → assistant-h0gv97mv.js} +20 -18
- package/dist/{boot-reembed-4kx69j3g.js → boot-reembed-qr8a894z.js} +1 -1
- package/dist/{boot-reembed-db0wwgy1.js → boot-reembed-rj85jjhw.js} +1 -1
- package/dist/{boot-scrub-sweep-tfv4d3mm.js → boot-scrub-sweep-eyfvn6v9.js} +3 -3
- package/dist/{boot-seeds-w2hmkxn5.js → boot-seeds-xs9a8dx1.js} +4 -4
- package/dist/{claude-adapter-5qrc2paq.js → claude-adapter-bm23cye7.js} +5 -5
- package/dist/{claude-managed-adapter-n7ahjyx6.js → claude-managed-adapter-1mwrsm9d.js} +2 -2
- package/dist/{claude-managed-setup-7ra32em4.js → claude-managed-setup-gkfgks4j.js} +1 -1
- package/dist/{claude-sdk-session-cd02gc76.js → claude-sdk-session-apb4ypz3.js} +5 -5
- package/dist/cli-02vhhssk.js +848 -0
- package/dist/{cli-4ync7m60.js → cli-0ebey9hg.js} +1 -1
- package/dist/{cli-665j3feb.js → cli-0xk505e8.js} +1 -1
- package/dist/{cli-8c1m9mhy.js → cli-1cevz46c.js} +5 -5
- package/dist/{cli-gzvb3d4a.js → cli-1jx0srjt.js} +3 -3
- package/dist/{cli-zj4d6afs.js → cli-1pa3rz69.js} +21 -6
- package/dist/{cli-vmbakbq0.js → cli-2rwet3r7.js} +1 -1
- package/dist/{cli-azs9bhc5.js → cli-3wjs22x3.js} +2 -2
- package/dist/{cli-tba8fxf9.js → cli-4fhyq711.js} +45 -36
- package/dist/{cli-ak4c12mh.js → cli-5ff2sjyt.js} +1 -1
- package/dist/{cli-6q7pyd3k.js → cli-5s5hx57y.js} +5 -3
- package/dist/{cli-h23mr2s6.js → cli-6ftrrwm1.js} +9 -371
- package/dist/{cli-9ac0mkg5.js → cli-8b9wvjps.js} +1 -1
- package/dist/cli-8cxkw1f5.js +382 -0
- package/dist/{cli-xhgtay7w.js → cli-8rwvksbc.js} +2 -2
- package/dist/{cli-e09hj50q.js → cli-8wkb09q5.js} +11 -11
- package/dist/{cli-ksrphveb.js → cli-a1esmmee.js} +14 -2
- package/dist/{cli-xnp2vkx7.js → cli-avmqhatn.js} +7 -7
- package/dist/{cli-xx13gqf3.js → cli-b80a1100.js} +1 -1
- package/dist/{cli-z1hh99j0.js → cli-bfc3k1x5.js} +3 -3
- package/dist/{cli-0k9rcwfy.js → cli-bhwa8rzc.js} +33 -15
- package/dist/{cli-8cvvytad.js → cli-bn26xbcs.js} +2 -2
- package/dist/{cli-k28p6xh7.js → cli-c8fwpgqb.js} +1 -1
- package/dist/{cli-rc3r0bnh.js → cli-ccn0eybf.js} +1 -1
- package/dist/{cli-9td5ss65.js → cli-dsgj3e7a.js} +2 -2
- package/dist/{cli-jrk8mj0t.js → cli-ecshf504.js} +2 -2
- package/dist/{cli-raejy0mp.js → cli-eqgfs202.js} +1 -1
- package/dist/{cli-ppapy34w.js → cli-f3nx1h8z.js} +2 -2
- package/dist/{cli-c9vrwcpb.js → cli-ffbrkh1r.js} +74 -35
- package/dist/{cli-mnry16kh.js → cli-kk2r80n5.js} +4 -4
- package/dist/{cli-c0dpzwnx.js → cli-kz116kvw.js} +3 -3
- package/dist/{cli-e5y285rg.js → cli-mvg2hf8p.js} +4 -4
- package/dist/{cli-2jm992rm.js → cli-mz1ezzzh.js} +2 -2
- package/dist/{cli-dw0njd9q.js → cli-nme7wery.js} +6 -6
- package/dist/{cli-mvsfbbgq.js → cli-nrrg0fvy.js} +18 -4
- package/dist/{cli-8jtrekpy.js → cli-p1m2b6gc.js} +7 -7
- package/dist/{cli-4c7k75rq.js → cli-rg7ntypm.js} +378 -84
- package/dist/{cli-z3dk189k.js → cli-rsd7ga7v.js} +10 -8
- package/dist/{cli-etvn5ej2.js → cli-sppc0cmw.js} +1 -1
- package/dist/{cli-8w65vwee.js → cli-tg9ksbn8.js} +1 -1
- package/dist/{cli-th8krqbf.js → cli-tgvpmnwz.js} +2 -2
- package/dist/{cli-hwc4a9xm.js → cli-tj4j2ts4.js} +6 -6
- package/dist/{cli-jhhb2v9n.js → cli-ttaz6c7m.js} +2 -2
- package/dist/{cli-w7z94e6c.js → cli-tx00c0h6.js} +4 -2
- package/dist/{cli-4g629sgr.js → cli-wrv09v7q.js} +20 -1
- package/dist/{cli-cby329ny.js → cli-wyrr38xm.js} +2 -2
- package/dist/{cli-5ks6x36q.js → cli-x1p5dhq2.js} +3 -3
- package/dist/{cli-bddxamg7.js → cli-x719j3vs.js} +220 -629
- package/dist/{cli-ztcxv5nv.js → cli-xfe63zx3.js} +1 -1
- package/dist/{cli-q6gw45wj.js → cli-ywyrffff.js} +1 -1
- package/dist/{cli-p46xdr1m.js → cli-zvze9rbm.js} +1 -1
- package/dist/cli.js +15 -15
- package/dist/{codex-adapter-m0fx3v3s.js → codex-adapter-pp2gq8gr.js} +3 -3
- package/dist/{codex-hook-x6tm6y5m.js → codex-hook-qwzccd64.js} +1 -1
- package/dist/{codex-session-runner-j9m2gfyy.js → codex-session-runner-0vzn09t8.js} +3 -3
- package/dist/{commands-ktnqwrv4.js → commands-psqhkttb.js} +2 -2
- package/dist/{controller-nex2he8v.js → controller-t20vz2k8.js} +2 -2
- package/dist/{controllers-connect-6yhwqv9v.js → controllers-connect-gkxmqw9d.js} +24 -20
- package/dist/{cursor-adapter-02s5ekva.js → cursor-adapter-3v542qe5.js} +1 -1
- package/dist/{db-jvr86v7f.js → db-2xbyenjr.js} +2 -2
- package/dist/{db-19tw9r5s.js → db-nc1rd869.js} +3 -2
- package/dist/{dispatcher-d3mnsg2y.js → dispatcher-ewceb8ts.js} +17 -15
- package/dist/{dsh-adapter-63rvej88.js → dsh-adapter-75xwh78t.js} +1 -1
- package/dist/{easter-egg-3d-6hg2r942.js → easter-egg-3d-xsc47jwf.js} +1 -1
- package/dist/{execute-bcqc69a5.js → execute-gzmw1mnw.js} +1 -1
- package/dist/{external-channel-44cwer32.js → external-channel-a5dsmxn4.js} +2 -2
- package/dist/{google-generative-ai-phpmwxvg.js → google-generative-ai-bmq8gkf3.js} +1 -1
- package/dist/{google-vertex-es9ybgdy.js → google-vertex-b1rvp52s.js} +1 -1
- package/dist/{grok-adapter-ysp907f6.js → grok-adapter-vj88xr23.js} +7 -7
- package/dist/handlers-zbs90bny.js +39 -0
- package/dist/{heartbeat-2etacsqq.js → heartbeat-t2m9r3wa.js} +20 -18
- package/dist/{hook-mzxxs5n2.js → hook-8femx1bg.js} +4 -4
- package/dist/{hook-3kvmax6d.js → hook-xa677f8r.js} +5 -5
- package/dist/{http-fn978m0g.js → http-xpmmq028.js} +940 -269
- package/dist/{index-e4tsa1h7.js → index-8pwc1jc8.js} +17 -15
- package/dist/{index-w2wr89xd.js → index-cdzyfrsp.js} +1 -1
- package/dist/{index-1r8hw4vd.js → index-dg2tmj99.js} +17 -15
- package/dist/index-nxa6me0q.js +41 -0
- package/dist/{index-039x2sag.js → index-s7ckdajq.js} +42 -14
- package/dist/{index-wgsw9xcz.js → index-syztqdt6.js} +1 -1
- package/dist/{index-x0q2a4f9.js → index-ysd45kgc.js} +1 -1
- package/dist/{index-g909y089.js → index-zgvqzjsw.js} +8 -8
- package/dist/{keepalive-d4nzs76x.js → keepalive-3nr4st3r.js} +3 -3
- package/dist/{lead-8h5n9k5f.js → lead-td8h2sfn.js} +33 -31
- package/dist/{maintenance-ea6jfw42.js → maintenance-verwpe2c.js} +1 -1
- package/dist/{oauth-refresh-sweep-3f25djq3.js → oauth-refresh-sweep-9q2t0y3q.js} +2 -2
- package/dist/{onboard-pvk7hdb2.js → onboard-aytqedmq.js} +2 -2
- package/dist/{opencode-adapter-eeesgfqk.js → opencode-adapter-f4hryjny.js} +4 -4
- package/dist/{otel-impl-qb7nhgdp.js → otel-impl-hckw4wt1.js} +1 -1
- package/dist/{pi-mono-adapter-zasp3ze3.js → pi-mono-adapter-hxby0ce1.js} +6 -6
- package/dist/{pricing-refresh-6ehwkcmp.js → pricing-refresh-5nvme3sm.js} +3 -3
- package/dist/{rbac-roles-km0f9cmm.js → rbac-roles-pd8c26m1.js} +5 -3
- package/dist/{rbac-roles-9648nh00.js → rbac-roles-vbpy21mg.js} +2 -2
- package/dist/{render-v2-xkze1wef.js → render-v2-7nqgvk2q.js} +3 -3
- package/dist/{resume-tq0bbvt5.js → resume-b6djcj3c.js} +6 -6
- package/dist/{runtime-ar8310ba.js → runtime-txrxc9za.js} +1 -1
- package/dist/session-access-fc3f9y12.js +25 -0
- package/dist/{setup-rnrazxvd.js → setup-n39szm6b.js} +1 -1
- package/dist/{virtual-modules-wzk6jf3a.js → virtual-modules-wyp2k6z3.js} +2 -2
- package/dist/{work-objects-e75q2rqs.js → work-objects-jem2fre0.js} +20 -18
- package/dist/{worker-4ww4hjef.js → worker-wr6xgfr2.js} +33 -31
- package/openapi.json +1011 -115
- package/package.json +2 -2
- package/src/apps/action-caller.ts +6 -0
- package/src/apps/script-types.ts +16 -3
- package/src/be/asset-key-audit.ts +4 -13
- package/src/be/db/active-sessions.ts +5 -0
- package/src/be/db/session-visibility.ts +29 -0
- package/src/be/db/tasks/read.ts +61 -3
- package/src/be/db/tasks/write.ts +15 -6
- package/src/be/db.ts +149 -66
- package/src/be/events.ts +11 -0
- package/src/be/migrations/227_inbox_item_state_last_read_at.sql +1 -0
- package/src/be/migrations/230_session_shares.sql +23 -0
- package/src/be/rbac-roles.ts +2 -0
- package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +22 -4
- package/src/be/session-shares.ts +244 -0
- package/src/be/swarm-config-guard.ts +23 -2
- package/src/commands/runner.ts +11 -0
- package/src/heartbeat/heartbeat.ts +25 -7
- package/src/http/active-sessions.ts +13 -2
- package/src/http/agents.ts +10 -3
- package/src/http/all-routes.ts +1 -0
- package/src/http/api-keys.ts +3 -1
- package/src/http/approval-requests.ts +2 -1
- package/src/http/apps.ts +36 -11
- package/src/http/assets.ts +26 -4
- package/src/http/config.ts +3 -0
- package/src/http/context.ts +2 -0
- package/src/http/core.ts +39 -2
- package/src/http/events.ts +6 -2
- package/src/http/favorite-owner.ts +1 -1
- package/src/http/fs.ts +38 -3
- package/src/http/inbox-state.ts +58 -8
- package/src/http/index.ts +3 -1
- package/src/http/memory.ts +3 -1
- package/src/http/ranking.ts +367 -0
- package/src/http/realtime.ts +26 -21
- package/src/http/route-def.ts +77 -11
- package/src/http/script-runs.ts +2 -1
- package/src/http/scripts.ts +8 -0
- package/src/http/session-access.ts +179 -0
- package/src/http/session-data.ts +10 -2
- package/src/http/sessions.ts +212 -12
- package/src/http/stats.ts +7 -3
- package/src/http/tasks.ts +83 -7
- package/src/http/used-by.ts +11 -3
- package/src/kv-reserved-namespaces.ts +1 -0
- package/src/prompts/session-templates.ts +3 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/ranking/telemetry-ranking.ts +147 -0
- package/src/ranking/usage.ts +57 -0
- package/src/rbac/admission.ts +22 -7
- package/src/rbac/legacy-policy.ts +2 -0
- package/src/rbac/permissions.ts +9 -0
- package/src/realtime/auth.ts +69 -0
- package/src/realtime/tickets.ts +4 -1
- package/src/realtime/transport.ts +77 -9
- package/src/slack/blocks.ts +18 -0
- package/src/slack/dm.ts +29 -0
- package/src/telemetry-contract/EVENTS.md +3 -0
- package/src/telemetry-contract/SOURCE.json +37 -4
- package/src/telemetry-contract/catalog.json +69 -0
- package/src/telemetry-contract/ranking/fixtures/expected.json +30 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/bad-category.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/bad-claim.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/blank-description.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/blank-name.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/description-too-long.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/domain-uppercase.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-scheme.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-www.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/http-source-url.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/missing-description.json +10 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/missing-version.json +10 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/name-not-string.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/name-too-long.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/not-object.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/null.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/other-with-subcategory.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/oversize.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/short-claim.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/subcategory-other-category.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/trailing-data.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/unknown-field.json +12 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/unsupported-version.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-not-date.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-offset.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/wrong-case-field.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/category-without-subcategory.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/full.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/minimal.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/padded-text.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/unicode-name.json +11 -0
- package/src/telemetry-contract/ranking/listing.v1.schema.json +61 -0
- package/src/telemetry-contract/ranking/taxonomy.json +8 -0
- package/src/telemetry-contract/ranking-taxonomy.gen.ts +11 -0
- package/src/telemetry-contract/types.gen.ts +708 -1
- package/src/telemetry.ts +26 -2
- package/src/tests/apps-rbac.test.ts +189 -2
- package/src/tests/apps-script-types.test.ts +16 -3
- package/src/tests/asset-key-api.test.ts +128 -0
- package/src/tests/asset-key-invariants.test.ts +179 -3
- package/src/tests/asset-key-migration.test.ts +35 -0
- package/src/tests/check-pr-body.test.ts +23 -0
- package/src/tests/check-ranking-entry.test.ts +146 -0
- package/src/tests/db-extraction-contract.test.ts +1 -0
- package/src/tests/db-task-read-contract.test.ts +1 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/heartbeat-checklist.test.ts +88 -0
- package/src/tests/heartbeat-jev-gate.test.ts +394 -10
- package/src/tests/inbox-state.test.ts +167 -0
- package/src/tests/kv-tool.test.ts +15 -0
- package/src/tests/org-identity.test.ts +44 -0
- package/src/tests/ranking-listing.test.ts +112 -0
- package/src/tests/ranking-route.test.ts +319 -0
- package/src/tests/ranking-usage.test.ts +50 -0
- package/src/tests/rbac-admission-e2e.test.ts +8 -0
- package/src/tests/rbac-admission.test.ts +38 -0
- package/src/tests/rbac-charact-http.test.ts +4 -3
- package/src/tests/rbac-engine.test.ts +2 -0
- package/src/tests/rbac-roles.test.ts +2 -0
- package/src/tests/realtime-room-surfaces.test.ts +3 -1
- package/src/tests/realtime-session-join-race.test.ts +142 -0
- package/src/tests/realtime-tickets.test.ts +24 -17
- package/src/tests/realtime-transport.test.ts +91 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/session-route-posture.test.ts +639 -0
- package/src/tests/session-sharing-routes.test.ts +282 -0
- package/src/tests/session-sharing.test.ts +526 -0
- package/src/tests/sessions-detail-batch.test.ts +7 -1
- package/src/tests/slack-tool-blocks.test.ts +101 -0
- package/src/tests/slack-update.test.ts +55 -0
- package/src/tests/slack-user-dm.test.ts +177 -0
- package/src/tests/used-by-route.test.ts +10 -0
- package/src/tools/slack-post.ts +18 -12
- package/src/tools/slack-read.ts +13 -1
- package/src/tools/slack-reply.ts +7 -10
- package/src/tools/slack-start-thread.ts +18 -11
- package/src/tools/slack-update.ts +2 -9
- package/src/types.ts +37 -0
- package/src/utils/org-identity.ts +33 -0
- package/templates/skills/apps/SKILL.md +17 -0
- package/templates/skills/apps/content.md +17 -0
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/pages/content.md +4 -0
- package/templates/skills/swarm-scripts/SKILL.md +6 -0
- package/templates/skills/swarm-scripts/content.md +6 -0
- package/dist/handlers-t5pbabwf.js +0 -37
- package/dist/index-qyjbb7br.js +0 -39
- package/dist/{cli-n5mrsg0f.js → cli-60cgh1rv.js} +3 -3
- /package/dist/{cli-j0v75y13.js → cli-y83zk5sb.js} +0 -0
|
@@ -128,6 +128,61 @@ describe("slack-update", () => {
|
|
|
128
128
|
});
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
test("splits a long message into sections under Slack's cap", async () => {
|
|
132
|
+
const message = Array.from(
|
|
133
|
+
{ length: 120 },
|
|
134
|
+
(_, index) => `line ${String(index).padStart(3, "0")} radar item with some words`,
|
|
135
|
+
)
|
|
136
|
+
.join("\n")
|
|
137
|
+
.slice(0, 3946);
|
|
138
|
+
const tool = buildTool();
|
|
139
|
+
const result = await tool.handler(
|
|
140
|
+
{ channelId: "C1", messageTs: "1783411554.596189", message },
|
|
141
|
+
meta(leadAgentId),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(result.structuredContent.success).toBe(true);
|
|
145
|
+
const payload = mockChatUpdate.mock.calls[0][0] as {
|
|
146
|
+
text: string;
|
|
147
|
+
blocks: { type: string; text: { type: string; text: string } }[];
|
|
148
|
+
};
|
|
149
|
+
expect(payload.text).toBe(message);
|
|
150
|
+
expect(payload.blocks.length).toBeGreaterThanOrEqual(2);
|
|
151
|
+
for (const block of payload.blocks) {
|
|
152
|
+
expect(block.type).toBe("section");
|
|
153
|
+
expect(block.text.text.length).toBeLessThanOrEqual(2900);
|
|
154
|
+
}
|
|
155
|
+
const joined = payload.blocks.map((block) => block.text.text).join("");
|
|
156
|
+
expect(joined.replace(/\s/g, "")).toBe(message.replace(/\s/g, ""));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("keeps a 2,931-char message with an emoji at 2,900 in one intact section", async () => {
|
|
160
|
+
const message = `${"x".repeat(2899)}😀${"y".repeat(30)}`;
|
|
161
|
+
expect(message.length).toBe(2931);
|
|
162
|
+
const tool = buildTool();
|
|
163
|
+
const result = await tool.handler(
|
|
164
|
+
{ channelId: "C1", messageTs: "1783411554.596189", message },
|
|
165
|
+
meta(leadAgentId),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(result.structuredContent.success).toBe(true);
|
|
169
|
+
expect(mockChatUpdate.mock.calls[0][0].blocks).toEqual([
|
|
170
|
+
{ type: "section", text: { type: "mrkdwn", text: message } },
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("keeps a short message in exactly one section", async () => {
|
|
175
|
+
const tool = buildTool();
|
|
176
|
+
await tool.handler(
|
|
177
|
+
{ channelId: "C1", messageTs: "1783411554.596189", message: "short" },
|
|
178
|
+
meta(leadAgentId),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(mockChatUpdate.mock.calls[0][0]).toMatchObject({
|
|
182
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text: "short" } }],
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
131
186
|
test.each([
|
|
132
187
|
["message_not_found", "No message found at that timestamp in this channel."],
|
|
133
188
|
[
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { closeDb, createAgent, getSlackMessageByChannelTs, initDb } from "../be/db";
|
|
5
|
+
import { isSlackUserId } from "../slack/dm";
|
|
6
|
+
import { registerSlackPostTool } from "../tools/slack-post";
|
|
7
|
+
import { registerSlackReadTool } from "../tools/slack-read";
|
|
8
|
+
import { registerSlackStartThreadTool } from "../tools/slack-start-thread";
|
|
9
|
+
|
|
10
|
+
const TEST_DB_PATH = "./test-slack-user-dm.sqlite";
|
|
11
|
+
const USER_ID = "U08USERDM";
|
|
12
|
+
// The bot's own IM with USER_ID: readable, writable, delivers message.im events.
|
|
13
|
+
const BOT_DM = "D0BOTDM01";
|
|
14
|
+
// Where Slack puts a bot post addressed to the bare user id: the bot is not a member.
|
|
15
|
+
const FOREIGN_DM = "D0FOREIGN";
|
|
16
|
+
|
|
17
|
+
function slackError(code: string): Error {
|
|
18
|
+
return Object.assign(new Error(`An API error occurred: ${code}`), {
|
|
19
|
+
data: { ok: false, error: code },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let tsCounter = 0;
|
|
24
|
+
const postMessage = mock(async (args: { channel: string; thread_ts?: string }) => {
|
|
25
|
+
if (args.channel === FOREIGN_DM) throw slackError("channel_not_found");
|
|
26
|
+
const ts = `500.${++tsCounter}`;
|
|
27
|
+
return { ok: true, ts, channel: args.channel === USER_ID ? FOREIGN_DM : args.channel };
|
|
28
|
+
});
|
|
29
|
+
const conversationsOpen = mock(async (args: { users: string }) => {
|
|
30
|
+
if (args.users !== USER_ID) throw slackError("user_not_found");
|
|
31
|
+
return { ok: true, channel: { id: BOT_DM } };
|
|
32
|
+
});
|
|
33
|
+
const conversationsReplies = mock(async (args: { channel: string; ts: string }) => {
|
|
34
|
+
if (args.channel !== BOT_DM) throw slackError("channel_not_found");
|
|
35
|
+
return { ok: true, messages: [{ user: USER_ID, text: "reply from human", ts: args.ts }] };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
mock.module("../slack/app", () => ({
|
|
39
|
+
getSlackApp: () => ({
|
|
40
|
+
client: {
|
|
41
|
+
chat: { postMessage },
|
|
42
|
+
conversations: { open: conversationsOpen, replies: conversationsReplies },
|
|
43
|
+
auth: { test: async () => ({ ok: true, user_id: "UBOT" }) },
|
|
44
|
+
users: { info: async () => ({ ok: true, user: { real_name: "Human" } }) },
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
type ToolResult = {
|
|
50
|
+
structuredContent: {
|
|
51
|
+
success: boolean;
|
|
52
|
+
message: string;
|
|
53
|
+
channelId?: string;
|
|
54
|
+
ts?: string;
|
|
55
|
+
messages?: Array<{ text: string }>;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
type RegisteredTool = { handler: (args: unknown, extra: unknown) => Promise<ToolResult> };
|
|
59
|
+
|
|
60
|
+
function buildTools() {
|
|
61
|
+
const server = new McpServer({ name: "slack-user-dm-test", version: "1.0.0" });
|
|
62
|
+
registerSlackPostTool(server);
|
|
63
|
+
registerSlackReadTool(server);
|
|
64
|
+
registerSlackStartThreadTool(server);
|
|
65
|
+
return (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
|
|
66
|
+
._registeredTools;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function meta(agentId: string) {
|
|
70
|
+
return { sessionId: "s1", requestInfo: { headers: { "x-agent-id": agentId } } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function removeDbFiles() {
|
|
74
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
75
|
+
try {
|
|
76
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let leadId: string;
|
|
82
|
+
|
|
83
|
+
beforeAll(async () => {
|
|
84
|
+
await removeDbFiles();
|
|
85
|
+
initDb(TEST_DB_PATH);
|
|
86
|
+
leadId = (await createAgent({ name: "Lead DM", isLead: true, status: "idle" })).id;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
postMessage.mockClear();
|
|
91
|
+
conversationsOpen.mockClear();
|
|
92
|
+
conversationsReplies.mockClear();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterAll(async () => {
|
|
96
|
+
closeDb();
|
|
97
|
+
await removeDbFiles();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("Slack tools addressed with a user id", () => {
|
|
101
|
+
test("isSlackUserId matches user ids only", () => {
|
|
102
|
+
expect(isSlackUserId("U08NR6QD6CS")).toBe(true);
|
|
103
|
+
expect(isSlackUserId("W012ABCDEF")).toBe(true);
|
|
104
|
+
expect(isSlackUserId("D0A4T383FMF")).toBe(false);
|
|
105
|
+
expect(isSlackUserId("C0AR967K0KZ")).toBe(false);
|
|
106
|
+
expect(isSlackUserId("G01PRIVATE")).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("slack-start-thread opens the bot DM and returns an id that reads and writes work on", async () => {
|
|
110
|
+
const tools = buildTools();
|
|
111
|
+
|
|
112
|
+
const started = await tools["slack-start-thread"]!.handler(
|
|
113
|
+
{ channelId: USER_ID, message: "hello" },
|
|
114
|
+
meta(leadId),
|
|
115
|
+
);
|
|
116
|
+
expect(started.structuredContent.success).toBe(true);
|
|
117
|
+
expect(conversationsOpen).toHaveBeenCalledWith({ users: USER_ID });
|
|
118
|
+
expect(postMessage.mock.calls[0]![0].channel).toBe(BOT_DM);
|
|
119
|
+
expect(started.structuredContent.channelId).toBe(BOT_DM);
|
|
120
|
+
const threadTs = started.structuredContent.ts!;
|
|
121
|
+
// The recorded message points at the bot DM, so inbound replies match it.
|
|
122
|
+
expect((await getSlackMessageByChannelTs(BOT_DM, threadTs))?.threadTs).toBe(threadTs);
|
|
123
|
+
|
|
124
|
+
const posted = await tools["slack-post"]!.handler(
|
|
125
|
+
{ channelId: BOT_DM, threadTs, message: "follow-up" },
|
|
126
|
+
meta(leadId),
|
|
127
|
+
);
|
|
128
|
+
expect(posted.structuredContent.success).toBe(true);
|
|
129
|
+
expect(postMessage.mock.calls[1]![0]).toMatchObject({ channel: BOT_DM, thread_ts: threadTs });
|
|
130
|
+
|
|
131
|
+
const read = await tools["slack-read"]!.handler({ channelId: BOT_DM, threadTs }, meta(leadId));
|
|
132
|
+
expect(read.structuredContent.success).toBe(true);
|
|
133
|
+
expect(read.structuredContent.messages?.[0]?.text).toContain("reply from human");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("slack-post and slack-read with the user id resolve to the same bot DM", async () => {
|
|
137
|
+
const tools = buildTools();
|
|
138
|
+
|
|
139
|
+
const posted = await tools["slack-post"]!.handler(
|
|
140
|
+
{ channelId: USER_ID, threadTs: "500.1", message: "by user id" },
|
|
141
|
+
meta(leadId),
|
|
142
|
+
);
|
|
143
|
+
expect(posted.structuredContent.success).toBe(true);
|
|
144
|
+
expect(postMessage.mock.calls[0]![0]).toMatchObject({ channel: BOT_DM, thread_ts: "500.1" });
|
|
145
|
+
|
|
146
|
+
const read = await tools["slack-read"]!.handler(
|
|
147
|
+
{ channelId: USER_ID, threadTs: "500.1" },
|
|
148
|
+
meta(leadId),
|
|
149
|
+
);
|
|
150
|
+
expect(read.structuredContent.success).toBe(true);
|
|
151
|
+
expect(conversationsReplies.mock.calls[0]![0].channel).toBe(BOT_DM);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("conversation ids pass through without opening a DM", async () => {
|
|
155
|
+
const tools = buildTools();
|
|
156
|
+
|
|
157
|
+
const started = await tools["slack-start-thread"]!.handler(
|
|
158
|
+
{ channelId: "C0CHANNEL1", message: "channel post" },
|
|
159
|
+
meta(leadId),
|
|
160
|
+
);
|
|
161
|
+
expect(started.structuredContent.channelId).toBe("C0CHANNEL1");
|
|
162
|
+
expect(postMessage.mock.calls[0]![0].channel).toBe("C0CHANNEL1");
|
|
163
|
+
expect(conversationsOpen).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a user id Slack cannot open a DM with fails without posting", async () => {
|
|
167
|
+
const tools = buildTools();
|
|
168
|
+
|
|
169
|
+
const started = await tools["slack-start-thread"]!.handler(
|
|
170
|
+
{ channelId: "U0NOBODY1", message: "hello" },
|
|
171
|
+
meta(leadId),
|
|
172
|
+
);
|
|
173
|
+
expect(started.structuredContent.success).toBe(false);
|
|
174
|
+
expect(started.structuredContent.message).toContain("user_not_found");
|
|
175
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -184,8 +184,18 @@ describe("POST /api/used-by", () => {
|
|
|
184
184
|
await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
|
|
185
185
|
const result = await post({ granted: true }, OPERATOR);
|
|
186
186
|
expect(result.statusCode).toBe(400);
|
|
187
|
+
expect(JSON.parse(result.body)).toEqual({
|
|
188
|
+
error: "Set the organization name and website first",
|
|
189
|
+
});
|
|
187
190
|
expect(proxied).toEqual([]);
|
|
188
191
|
});
|
|
192
|
+
|
|
193
|
+
test("a placeholder name still grants: the ranking's placeholder rule does not apply", async () => {
|
|
194
|
+
await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "Swarm" });
|
|
195
|
+
const result = await post({ granted: true }, OPERATOR);
|
|
196
|
+
expect(result.statusCode).toBe(200);
|
|
197
|
+
expect(proxied[0]?.body).toMatchObject({ org_name: "Swarm", org_website: "acme.com" });
|
|
198
|
+
});
|
|
189
199
|
});
|
|
190
200
|
|
|
191
201
|
describe("fillOrgLogoFromWebsite", () => {
|
package/src/tools/slack-post.ts
CHANGED
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
} from "@/be/db";
|
|
9
9
|
import { can } from "@/rbac";
|
|
10
10
|
import { getSlackApp } from "@/slack/app";
|
|
11
|
-
import { getTaskLink } from "@/slack/blocks";
|
|
11
|
+
import { buildMrkdwnSectionBlocks, getTaskLink } from "@/slack/blocks";
|
|
12
12
|
import { withAutoJoin } from "@/slack/channel-join";
|
|
13
|
+
import { resolveSlackConversationId } from "@/slack/dm";
|
|
13
14
|
import { externalChannelToolError, getChannelBlockReason } from "@/slack/external-channel";
|
|
14
15
|
import { getAgentDisplayName, getAgentEmoji, markdownToSlack } from "@/slack/responses";
|
|
15
16
|
import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
|
|
@@ -24,7 +25,12 @@ export const registerSlackPostTool = (server: McpServer) => {
|
|
|
24
25
|
annotations: { openWorldHint: true },
|
|
25
26
|
|
|
26
27
|
inputSchema: z.object({
|
|
27
|
-
channelId: z
|
|
28
|
+
channelId: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.describe(
|
|
32
|
+
"The Slack channel ID to post to. A user ID (U…/W…) posts in the bot's DM with that user.",
|
|
33
|
+
),
|
|
28
34
|
message: z.string().min(1).max(4000).describe("The message content to post."),
|
|
29
35
|
blocks: z
|
|
30
36
|
.array(z.record(z.string(), z.unknown()))
|
|
@@ -42,7 +48,7 @@ export const registerSlackPostTool = (server: McpServer) => {
|
|
|
42
48
|
messageTs: z.string().optional(),
|
|
43
49
|
}),
|
|
44
50
|
},
|
|
45
|
-
async ({ channelId, message, threadTs, blocks }, requestInfo, _meta) => {
|
|
51
|
+
async ({ channelId: requestedChannelId, message, threadTs, blocks }, requestInfo, _meta) => {
|
|
46
52
|
if (!requestInfo.agentId) {
|
|
47
53
|
return toolErr("Agent ID not found.");
|
|
48
54
|
}
|
|
@@ -68,6 +74,14 @@ export const registerSlackPostTool = (server: McpServer) => {
|
|
|
68
74
|
return toolErr("Slack not configured.");
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
let channelId: string;
|
|
78
|
+
try {
|
|
79
|
+
channelId = await resolveSlackConversationId(app.client, requestedChannelId);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
82
|
+
return toolErr(`Failed to open a DM with ${requestedChannelId}: ${errorMsg}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
71
85
|
const blockReason = await getChannelBlockReason(app.client, channelId);
|
|
72
86
|
if (blockReason) {
|
|
73
87
|
return toolErr(externalChannelToolError(channelId, blockReason));
|
|
@@ -82,15 +96,7 @@ export const registerSlackPostTool = (server: McpServer) => {
|
|
|
82
96
|
const contextKey = sourceTask?.contextKey;
|
|
83
97
|
const tree = threadTs ? await getSlackTreeMessageByThread(channelId, threadTs) : null;
|
|
84
98
|
const messageBlocks: Record<string, unknown>[] = [
|
|
85
|
-
...(blocks ??
|
|
86
|
-
{
|
|
87
|
-
type: "section",
|
|
88
|
-
text: {
|
|
89
|
-
type: "mrkdwn",
|
|
90
|
-
text: slackMessage,
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
]),
|
|
99
|
+
...(blocks ?? buildMrkdwnSectionBlocks(slackMessage)),
|
|
94
100
|
];
|
|
95
101
|
if (sourceTask && tree) {
|
|
96
102
|
if (messageBlocks.length >= 50) {
|
package/src/tools/slack-read.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getAgentById, getInboxMessageById, getTaskById } from "@/be/db";
|
|
|
4
4
|
import { can } from "@/rbac";
|
|
5
5
|
import { getSlackApp } from "@/slack/app";
|
|
6
6
|
import { withAutoJoin } from "@/slack/channel-join";
|
|
7
|
+
import { resolveSlackConversationId } from "@/slack/dm";
|
|
7
8
|
import { externalChannelToolError, getChannelBlockReason } from "@/slack/external-channel";
|
|
8
9
|
import { downloadFile, type SlackFile } from "@/slack/files";
|
|
9
10
|
import { attachableTask, attachSlackFilesForAgent } from "@/slack/inbound-files";
|
|
@@ -59,7 +60,9 @@ export const registerSlackReadTool = (server: McpServer) => {
|
|
|
59
60
|
channelId: z
|
|
60
61
|
.string()
|
|
61
62
|
.optional()
|
|
62
|
-
.describe(
|
|
63
|
+
.describe(
|
|
64
|
+
"Slack channel ID to read from (requires lead privileges). A user ID (U…/W…) reads the bot's DM with that user.",
|
|
65
|
+
),
|
|
63
66
|
threadTs: z
|
|
64
67
|
.string()
|
|
65
68
|
.optional()
|
|
@@ -155,6 +158,15 @@ export const registerSlackReadTool = (server: McpServer) => {
|
|
|
155
158
|
return toolErr("Slack not configured.", { data: { messages: [] } });
|
|
156
159
|
}
|
|
157
160
|
|
|
161
|
+
try {
|
|
162
|
+
slackChannelId = await resolveSlackConversationId(app.client, slackChannelId);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
165
|
+
return toolErr(`Failed to open a DM with ${slackChannelId}: ${errorMsg}`, {
|
|
166
|
+
data: { messages: [] },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
158
170
|
const blockReason = await getChannelBlockReason(app.client, slackChannelId);
|
|
159
171
|
if (blockReason) {
|
|
160
172
|
return toolErr(externalChannelToolError(slackChannelId, blockReason), {
|
package/src/tools/slack-reply.ts
CHANGED
|
@@ -11,7 +11,12 @@ import {
|
|
|
11
11
|
} from "@/be/db";
|
|
12
12
|
import { getTaskCitations } from "@/be/task-citations";
|
|
13
13
|
import { getSlackApp } from "@/slack/app";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
buildCaptionBlock,
|
|
16
|
+
buildMrkdwnSectionBlocks,
|
|
17
|
+
getTaskLink,
|
|
18
|
+
getTaskUrl,
|
|
19
|
+
} from "@/slack/blocks";
|
|
15
20
|
import { withAutoJoin } from "@/slack/channel-join";
|
|
16
21
|
import { externalChannelToolError, getChannelBlockReason } from "@/slack/external-channel";
|
|
17
22
|
import { getAgentDisplayName, getAgentEmoji, markdownToSlack } from "@/slack/responses";
|
|
@@ -140,15 +145,7 @@ export const registerSlackReplyTool = (server: McpServer) => {
|
|
|
140
145
|
|
|
141
146
|
const tree = await getSlackTreeMessageByThread(slackChannelId, slackThreadTs);
|
|
142
147
|
const messageBlocks: Record<string, unknown>[] = [
|
|
143
|
-
...(renderedBlocks ??
|
|
144
|
-
{
|
|
145
|
-
type: "section",
|
|
146
|
-
text: {
|
|
147
|
-
type: "mrkdwn",
|
|
148
|
-
text: slackMessage,
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
]),
|
|
148
|
+
...(renderedBlocks ?? buildMrkdwnSectionBlocks(slackMessage)),
|
|
152
149
|
];
|
|
153
150
|
for (const group of taskCitationSourceGroups(
|
|
154
151
|
blocks ? JSON.stringify(blocks) : message,
|
|
@@ -3,7 +3,9 @@ import * as z from "zod";
|
|
|
3
3
|
import { getAgentById, getTaskById, recordSlackMessage } from "@/be/db";
|
|
4
4
|
import { can } from "@/rbac";
|
|
5
5
|
import { getSlackApp } from "@/slack/app";
|
|
6
|
+
import { buildMrkdwnSectionBlocks } from "@/slack/blocks";
|
|
6
7
|
import { withAutoJoin } from "@/slack/channel-join";
|
|
8
|
+
import { resolveSlackConversationId } from "@/slack/dm";
|
|
7
9
|
import { externalChannelToolError, getChannelBlockReason } from "@/slack/external-channel";
|
|
8
10
|
import { getAgentDisplayName, getAgentEmoji, markdownToSlack } from "@/slack/responses";
|
|
9
11
|
import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
|
|
@@ -18,7 +20,12 @@ export const registerSlackStartThreadTool = (server: McpServer) => {
|
|
|
18
20
|
annotations: { openWorldHint: true },
|
|
19
21
|
|
|
20
22
|
inputSchema: z.object({
|
|
21
|
-
channelId: z
|
|
23
|
+
channelId: z
|
|
24
|
+
.string()
|
|
25
|
+
.min(1)
|
|
26
|
+
.describe(
|
|
27
|
+
"The Slack channel ID to post to. A user ID (U…/W…) opens the bot's DM with that user; use the returned channelId afterwards.",
|
|
28
|
+
),
|
|
22
29
|
message: z.string().min(1).max(4000).describe("The message content to post."),
|
|
23
30
|
blocks: z
|
|
24
31
|
.array(z.record(z.string(), z.unknown()))
|
|
@@ -32,7 +39,7 @@ export const registerSlackStartThreadTool = (server: McpServer) => {
|
|
|
32
39
|
messageTs: z.string().optional(),
|
|
33
40
|
}),
|
|
34
41
|
},
|
|
35
|
-
async ({ channelId, message, blocks }, requestInfo, _meta) => {
|
|
42
|
+
async ({ channelId: requestedChannelId, message, blocks }, requestInfo, _meta) => {
|
|
36
43
|
if (!requestInfo.agentId) {
|
|
37
44
|
return toolErr("Agent ID not found.");
|
|
38
45
|
}
|
|
@@ -57,6 +64,14 @@ export const registerSlackStartThreadTool = (server: McpServer) => {
|
|
|
57
64
|
return toolErr("Slack not configured.");
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
let channelId: string;
|
|
68
|
+
try {
|
|
69
|
+
channelId = await resolveSlackConversationId(app.client, requestedChannelId);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
72
|
+
return toolErr(`Failed to open a DM with ${requestedChannelId}: ${errorMsg}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
const blockReason = await getChannelBlockReason(app.client, channelId);
|
|
61
76
|
if (blockReason) {
|
|
62
77
|
return toolErr(externalChannelToolError(channelId, blockReason));
|
|
@@ -64,15 +79,7 @@ export const registerSlackStartThreadTool = (server: McpServer) => {
|
|
|
64
79
|
|
|
65
80
|
try {
|
|
66
81
|
const slackMessage = markdownToSlack(message);
|
|
67
|
-
const messageBlocks = blocks ??
|
|
68
|
-
{
|
|
69
|
-
type: "section",
|
|
70
|
-
text: {
|
|
71
|
-
type: "mrkdwn",
|
|
72
|
-
text: slackMessage,
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
];
|
|
82
|
+
const messageBlocks = blocks ?? buildMrkdwnSectionBlocks(slackMessage);
|
|
76
83
|
|
|
77
84
|
const result = await withAutoJoin(app.client, channelId, () =>
|
|
78
85
|
app.client.chat.postMessage({
|
|
@@ -4,6 +4,7 @@ import * as z from "zod";
|
|
|
4
4
|
import { getAgentById } from "@/be/db";
|
|
5
5
|
import { can } from "@/rbac";
|
|
6
6
|
import { getSlackApp } from "@/slack/app";
|
|
7
|
+
import { buildMrkdwnSectionBlocks } from "@/slack/blocks";
|
|
7
8
|
import { externalChannelToolError, getChannelBlockReason } from "@/slack/external-channel";
|
|
8
9
|
import { parseSlackTs } from "@/slack/message-text";
|
|
9
10
|
import { markdownToSlack } from "@/slack/responses";
|
|
@@ -77,15 +78,7 @@ export const registerSlackUpdateTool = (server: McpServer) => {
|
|
|
77
78
|
text: slackMessage,
|
|
78
79
|
unfurl_links: false,
|
|
79
80
|
unfurl_media: false,
|
|
80
|
-
blocks:
|
|
81
|
-
{
|
|
82
|
-
type: "section",
|
|
83
|
-
text: {
|
|
84
|
-
type: "mrkdwn",
|
|
85
|
-
text: slackMessage,
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
|
-
],
|
|
81
|
+
blocks: buildMrkdwnSectionBlocks(slackMessage),
|
|
89
82
|
} satisfies ChatUpdatePayload;
|
|
90
83
|
const result = await app.client.chat.update(updatePayload);
|
|
91
84
|
|
package/src/types.ts
CHANGED
|
@@ -453,6 +453,41 @@ export const ModelProfileSchema = z.object({
|
|
|
453
453
|
});
|
|
454
454
|
export type ModelProfile = z.infer<typeof ModelProfileSchema>;
|
|
455
455
|
|
|
456
|
+
// ============================================================================
|
|
457
|
+
// Session sharing
|
|
458
|
+
// ============================================================================
|
|
459
|
+
//
|
|
460
|
+
// `SessionShareAccessSchema` MUST stay in sync with the SQL CHECK constraint
|
|
461
|
+
// on session_shares.access (migration 230).
|
|
462
|
+
|
|
463
|
+
export const SessionShareAccessSchema = z.enum(["read", "write"]);
|
|
464
|
+
export type SessionShareAccess = z.infer<typeof SessionShareAccessSchema>;
|
|
465
|
+
|
|
466
|
+
/** The caller's access to a session. `none` is never returned; it is a 403. */
|
|
467
|
+
export const SessionAccessSchema = z.enum(["owner", "write", "read"]);
|
|
468
|
+
|
|
469
|
+
export const SessionOwnerSchema = z
|
|
470
|
+
.object({ id: z.string(), name: z.string() })
|
|
471
|
+
.openapi("SessionOwner");
|
|
472
|
+
|
|
473
|
+
export const SessionSharesSchema = z
|
|
474
|
+
.object({
|
|
475
|
+
/** What every user may do. `none` = private (owner and named users only). */
|
|
476
|
+
everyone: z.enum(["none", "read", "write"]),
|
|
477
|
+
users: z.array(
|
|
478
|
+
z.object({ userId: z.string(), access: SessionShareAccessSchema, createdAt: z.string() }),
|
|
479
|
+
),
|
|
480
|
+
})
|
|
481
|
+
.openapi("SessionShares");
|
|
482
|
+
export type SessionShares = z.infer<typeof SessionSharesSchema>;
|
|
483
|
+
|
|
484
|
+
export const SessionSharesUpdateSchema = z.object({
|
|
485
|
+
everyone: z.enum(["none", "read", "write"]),
|
|
486
|
+
users: z
|
|
487
|
+
.array(z.object({ userId: z.string().min(1), access: SessionShareAccessSchema }))
|
|
488
|
+
.max(500),
|
|
489
|
+
});
|
|
490
|
+
|
|
456
491
|
// ============================================================================
|
|
457
492
|
// Task Steering Messages
|
|
458
493
|
// ============================================================================
|
|
@@ -1114,6 +1149,8 @@ export const InboxItemStateSchema = z
|
|
|
1114
1149
|
doneAt: z.string().optional(),
|
|
1115
1150
|
/** First-viewed timestamp, set once on first panel open. Independent of status. */
|
|
1116
1151
|
readAt: z.string().optional(),
|
|
1152
|
+
/** Read cursor: latest time the user marked the item read. Clients can set, move back, or clear it. Independent of readAt and status. */
|
|
1153
|
+
lastReadAt: z.string().optional(),
|
|
1117
1154
|
createdAt: z.string(),
|
|
1118
1155
|
lastUpdatedAt: z.string(),
|
|
1119
1156
|
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "is the org identity set" rule, shared by every feature that needs a real
|
|
3
|
+
* organization name and website (the public ranking, the "used by" grant).
|
|
4
|
+
* Pure: the API passes global config values, workers pass `process.env`.
|
|
5
|
+
*/
|
|
6
|
+
import { normalizeOrgWebsite } from "./org-website";
|
|
7
|
+
|
|
8
|
+
export const ORG_IDENTITY_KEYS = ["SWARM_ORG_NAME", "SWARM_ORG_WEBSITE"] as const;
|
|
9
|
+
export type OrgIdentityKey = (typeof ORG_IDENTITY_KEYS)[number];
|
|
10
|
+
|
|
11
|
+
/** Names the product falls back to when none is set; they are not an org name. */
|
|
12
|
+
const PLACEHOLDER_NAMES = new Set(["swarm", "your swarm"]);
|
|
13
|
+
|
|
14
|
+
export interface OrgIdentityStatus {
|
|
15
|
+
/** The trimmed name, or null when blank. A placeholder is returned but listed as missing. */
|
|
16
|
+
name: string | null;
|
|
17
|
+
/** The website as a bare hostname, or null when blank or not a public hostname. */
|
|
18
|
+
website: string | null;
|
|
19
|
+
missing: OrgIdentityKey[];
|
|
20
|
+
ok: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function orgIdentityStatus(src: {
|
|
24
|
+
SWARM_ORG_NAME?: string | null;
|
|
25
|
+
SWARM_ORG_WEBSITE?: string | null;
|
|
26
|
+
}): OrgIdentityStatus {
|
|
27
|
+
const name = src.SWARM_ORG_NAME?.trim() || null;
|
|
28
|
+
const website = normalizeOrgWebsite(src.SWARM_ORG_WEBSITE) || null;
|
|
29
|
+
const missing: OrgIdentityKey[] = [];
|
|
30
|
+
if (!name || PLACEHOLDER_NAMES.has(name.toLowerCase())) missing.push("SWARM_ORG_NAME");
|
|
31
|
+
if (!website) missing.push("SWARM_ORG_WEBSITE");
|
|
32
|
+
return { name, website, missing, ok: missing.length === 0 };
|
|
33
|
+
}
|
|
@@ -30,6 +30,23 @@ For every edit: `app-get` -> modify the smallest coherent subtree -> `app-patch`
|
|
|
30
30
|
|
|
31
31
|
One `app-get` returns everything needed to drive an app you did not write: `definition.queries` are runnable with `app-query` (supply every `$param` the query references), and `definition.actions` are invocable with `POST /api/apps/<id>/actions/<name>` (a `script` action runs its script, a `task` action spawns a task, a `sync` action refreshes sources). Read a query's `$param` placeholders and an action's kind/args from the definition before calling — do not guess parameter names.
|
|
32
32
|
|
|
33
|
+
### Verified action caller
|
|
34
|
+
|
|
35
|
+
Script actions receive `caller` as a reserved, server-stamped argument. The server overwrites `caller` from definition defaults and request input.
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
type AppActionCaller = {
|
|
39
|
+
kind: "user" | "agent" | "operator" | "guest";
|
|
40
|
+
id: string | null;
|
|
41
|
+
name?: string;
|
|
42
|
+
pageId?: string;
|
|
43
|
+
};
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The caller identifies the authenticated user or agent. Operator and guest callers have `id: null`. A signed page session adds `pageId`.
|
|
47
|
+
With the swarm key, a registered `X-Agent-ID` produces an agent caller and `agent:<id>` row provenance. Missing or unknown agent IDs remain operator callers.
|
|
48
|
+
Task actions include the same caller as server-verified JSON in the task prompt. Script execution still uses the saved script owner's bindings.
|
|
49
|
+
|
|
33
50
|
## Safe schema evolution and rollback
|
|
34
51
|
|
|
35
52
|
Prefer **hiding** a column over deleting it. A hidden column's values stay on every existing row, so an unhide or rollback can make it visible again. Hiding is metadata-only: hidden columns cannot be used in queries, page bindings, `app.mutate` values, or new row writes, and `required` is ignored while hidden. Row GETs still expose the historical value for backward compatibility.
|
|
@@ -25,6 +25,23 @@ For every edit: `app-get` -> modify the smallest coherent subtree -> `app-patch`
|
|
|
25
25
|
|
|
26
26
|
One `app-get` returns everything needed to drive an app you did not write: `definition.queries` are runnable with `app-query` (supply every `$param` the query references), and `definition.actions` are invocable with `POST /api/apps/<id>/actions/<name>` (a `script` action runs its script, a `task` action spawns a task, a `sync` action refreshes sources). Read a query's `$param` placeholders and an action's kind/args from the definition before calling — do not guess parameter names.
|
|
27
27
|
|
|
28
|
+
### Verified action caller
|
|
29
|
+
|
|
30
|
+
Script actions receive `caller` as a reserved, server-stamped argument. The server overwrites `caller` from definition defaults and request input.
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
type AppActionCaller = {
|
|
34
|
+
kind: "user" | "agent" | "operator" | "guest";
|
|
35
|
+
id: string | null;
|
|
36
|
+
name?: string;
|
|
37
|
+
pageId?: string;
|
|
38
|
+
};
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The caller identifies the authenticated user or agent. Operator and guest callers have `id: null`. A signed page session adds `pageId`.
|
|
42
|
+
With the swarm key, a registered `X-Agent-ID` produces an agent caller and `agent:<id>` row provenance. Missing or unknown agent IDs remain operator callers.
|
|
43
|
+
Task actions include the same caller as server-verified JSON in the task prompt. Script execution still uses the saved script owner's bindings.
|
|
44
|
+
|
|
28
45
|
## Safe schema evolution and rollback
|
|
29
46
|
|
|
30
47
|
Prefer **hiding** a column over deleting it. A hidden column's values stay on every existing row, so an unhide or rollback can make it visible again. Hiding is metadata-only: hidden columns cannot be used in queries, page bindings, `app.mutate` values, or new row writes, and `required` is ignored while hidden. Row GETs still expose the historical value for backward compatibility.
|
|
@@ -43,7 +43,7 @@ The Repository Guidelines carry `allowMerge` and `mergeChecks`.
|
|
|
43
43
|
1. Read `/review/rules.md` on the shared agent-fs drive. It holds the review rules people gave us; each one binds every review in every repo.
|
|
44
44
|
2. Read the repo's `REVIEW.md` when it has one. It holds the repo-specific checks.
|
|
45
45
|
3. Set the effort from the PR body's `Risk:` line. `low`: a quick pass that still covers CI, tests and the security read. `medium` or no line: the full list below. `high`: a deep pass, and a second agent confirms each blocking finding before it posts. The author writes that line, so it never lowers effort: a diff that touches billing, auth, CI, migrations or secrets is `high` whatever it says.
|
|
46
|
-
4. Re-review check. If you reviewed this PR before, compare `gh pr view <number> --json headRefOid --jq .headRefOid` with the `commit_id` of your last review (`gh api repos/<owner>/<repo>/pulls/<number>/reviews`). Same SHA: reuse that verdict, link it and stop. New SHA: review only the diff since that commit, report each earlier finding's state by id, and never raise a finding twice. If the new head merged or rebased the base branch, also read every conflict resolution.
|
|
46
|
+
4. Re-review check. If you reviewed this PR before, compare `gh pr view <number> --json headRefOid --jq .headRefOid` with the `commit_id` of your last review (`gh api repos/<owner>/<repo>/pulls/<number>/reviews`). Same SHA: reuse that verdict, link it and stop. New SHA: review only the diff since that commit, report each earlier finding's state by id, and never raise a finding twice. Re-run each earlier repro on the new head. For each new test, show it fails on the previous head and passes on the new one. Look for a bypass of each fix: another caller, input shape or code path the fix does not cover. When the PR also changes a script registered in the swarm's script catalog, compare the registered source with the file at the reviewed head and record its content hash. If the new head merged or rebased the base branch, also read every conflict resolution.
|
|
47
47
|
5. Round cap. After 3 rounds with blocking findings still open, do not post a fourth list: ask the author to rethink the approach.
|
|
48
48
|
|
|
49
49
|
### What to check
|
|
@@ -99,7 +99,7 @@ Nothing reaches GitHub without passing `review-post-gate`.
|
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
On a PR the reviewing account authored, the gate returns `event: "COMMENT"`, because GitHub refuses APPROVE and REQUEST_CHANGES there. Post it as is.
|
|
102
|
-
4. Record the evidence (tests run, controls, "checked, fine" notes, the gate's `logKey`) in your task output and with `script-run name="pr-review-ledger-write"` (`action: "round"`). Never on the PR.
|
|
102
|
+
4. Record the evidence (tests run, controls, "checked, fine" notes, the gate's `logKey`) in your task output and with `script-run name="pr-review-ledger-write" scope="global"` (`action: "round"`). Never on the PR. If the ledger write fails, keep the exact payload and the error in your task output, report it to the lead once, and finish the review. Read the PR's existing ledger rounds before any retry so a round is never written twice.
|
|
103
103
|
5. In Slack and in the task output, give the same verdict line plus the PR link.
|
|
104
104
|
|
|
105
105
|
If the gate cannot run, post nothing to GitHub. Keep the draft in your task output and report the gate failure (the error and the time) to the lead.
|
|
@@ -158,6 +158,10 @@ Setting these properties only on `:root` does not replace the injected body styl
|
|
|
158
158
|
|
|
159
159
|
# Reference
|
|
160
160
|
|
|
161
|
+
## QA a published page
|
|
162
|
+
|
|
163
|
+
To check a page by screenshot, open it from an origin its data endpoints allow. A page that fetches live data from a CORS-restricted endpoint falls back to its baked values when opened from any other origin (for example an internal service hostname), so a screenshot there proves layout only, never the live numbers. Check every element you changed and its neighbours at the viewport readers use.
|
|
164
|
+
|
|
161
165
|
## Auth Modes
|
|
162
166
|
|
|
163
167
|
| Mode | URL behavior | When to use |
|