@cohortapp/agent-sdk 2.5.1 → 2.6.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/bin/maestro.mjs +185 -88
- package/bin/maestro.test.mjs +175 -48
- package/docs/runbooks/backup-restore.md +65 -33
- package/framework-features.json +4 -4
- package/lib/backup/policy.mjs +710 -0
- package/lib/backup/policy.test.mjs +305 -0
- package/lib/budget-escalate.mjs +133 -0
- package/lib/budget-escalate.test.mjs +232 -0
- package/lib/budget-guard.envelope.test.mjs +476 -0
- package/lib/budget-guard.mjs +853 -75
- package/lib/budget-guard.test.mjs +91 -42
- package/lib/cadences.mjs +33 -0
- package/lib/channels/orgmail/adapter.mjs +88 -3
- package/lib/channels/orgmail/adapter.test.mjs +137 -0
- package/lib/channels/repeat-suppressor.mjs +198 -0
- package/lib/channels/repeat-suppressor.test.mjs +134 -0
- package/lib/comms/receipts.mjs +297 -0
- package/lib/cost/ledger-row.mjs +333 -0
- package/lib/cost/ledger-row.test.mjs +183 -0
- package/lib/execution/drive.mjs +28 -1
- package/lib/execution/effects.mjs +191 -12
- package/lib/execution/effects.test.mjs +50 -11
- package/lib/goals/admission.mjs +13 -1
- package/lib/goals/admission.test.mjs +26 -1
- package/lib/goals/loop.mjs +13 -0
- package/lib/kpi-sensors.test.mjs +3 -0
- package/lib/mandate/cache.mjs +13 -5
- package/lib/mandate/derive.mjs +146 -21
- package/lib/mandate/derive.test.mjs +50 -6
- package/lib/mandate/model.mjs +32 -4
- package/lib/mandate/refresh.test.mjs +16 -2
- package/lib/mcp/server.test.mjs +12 -3
- package/lib/model-router/economics.mjs +107 -76
- package/lib/model-router/economics.test.mjs +64 -46
- package/lib/model-router/integration-coverage.test.mjs +39 -37
- package/lib/model-router/ledger.mjs +75 -22
- package/lib/model-router/ledger.test.mjs +35 -2
- package/lib/org/client.mjs +14 -0
- package/lib/org/cost-sync.mjs +16 -2
- package/lib/org/doctor.mjs +62 -1
- package/lib/org/doctor.test.mjs +36 -3
- package/lib/org/email-remedy.mjs +49 -0
- package/lib/org/engagement-ledger.mjs +376 -0
- package/lib/org/engagement-ledger.test.mjs +112 -0
- package/lib/org/engagement.mjs +1056 -0
- package/lib/org/engagement.test.mjs +739 -0
- package/lib/org/messaging.mjs +230 -3
- package/lib/org/messaging.test.mjs +110 -1
- package/lib/org/param-contract.mjs +56 -2
- package/lib/org/param-contract.test.mjs +26 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +5 -0
- package/lib/org/protocol.test.mjs +7 -1
- package/lib/org/tool-surface.mjs +506 -10
- package/lib/org/tool-surface.test.mjs +191 -7
- package/lib/org/ui-parity.mjs +333 -6
- package/lib/org/ui-parity.test.mjs +96 -3
- package/lib/org/work-ledger.mjs +241 -0
- package/lib/org/work-ledger.test.mjs +237 -0
- package/lib/plan/adoption-e2e.test.mjs +366 -0
- package/lib/plan/budget-enforcement.test.mjs +400 -0
- package/lib/plan/budget-runtime.mjs +215 -0
- package/lib/plan/compile.mjs +201 -5
- package/lib/plan/compile.test.mjs +19 -5
- package/lib/plan/emit.mjs +8 -0
- package/lib/plan/emit.test.mjs +18 -0
- package/lib/resource-governor.mjs +58 -12
- package/lib/resource-governor.test.mjs +41 -1
- package/lib/security/audit-engine.mjs +45 -8
- package/lib/security/audit-engine.test.mjs +35 -0
- package/lib/setup/enroll-from-cohort.mjs +14 -1
- package/lib/setup/sections/mandate.mjs +48 -7
- package/lib/setup/sections/mandate.test.mjs +17 -2
- package/lib/setup/sections/orgmail.mjs +10 -2
- package/lib/setup/state.mjs +83 -2
- package/lib/telemetry/collect.mjs +360 -20
- package/lib/telemetry/collect.test.mjs +266 -0
- package/package.json +1 -1
- package/scripts/cost/track-claude-usage.mjs +207 -48
- package/scripts/cost/track-claude-usage.test.mjs +148 -0
- package/scripts/daemon/agent-daemon.mjs +315 -17
- package/scripts/daemon/assurance-e2e.test.mjs +421 -0
- package/scripts/daemon/assurance.mjs +944 -0
- package/scripts/daemon/assurance.test.mjs +668 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
- package/scripts/daemon/cadence-consumer.mjs +147 -9
- package/scripts/daemon/cadence-consumer.test.mjs +6 -0
- package/scripts/daemon/cadence-handlers.mjs +158 -0
- package/scripts/daemon/cadence-handlers.test.mjs +64 -0
- package/scripts/daemon/deliver.mjs +314 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
- package/scripts/daemon/dispatcher.mjs +64 -6
- package/scripts/daemon/responder-cost.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +351 -298
- package/scripts/local-triggers/generate-plists.test.mjs +7 -4
- package/scripts/maintenance/backup-run.mjs +415 -0
- package/scripts/maintenance/backup-to-cloud.sh +16 -116
- package/scripts/org/send-orgmail.mjs +16 -0
- package/scripts/record-receipt.sh +63 -0
- package/scripts/restore-from-backup.sh +14 -3
- package/scripts/restore-from-backup.test.mjs +8 -5
- package/scripts/send-email-threaded.py +47 -0
- package/scripts/send-sms.sh +4 -0
- package/scripts/send-whatsapp.sh +4 -0
- package/scripts/setup/init-backup.mjs +93 -38
- package/scripts/slack-send.sh +12 -0
package/lib/org/messaging.mjs
CHANGED
|
@@ -55,6 +55,7 @@ import { isEnabled, configFromAgent, call, read } from "./client.mjs";
|
|
|
55
55
|
import { errFrame } from "./protocol.mjs";
|
|
56
56
|
import { screenOutbound } from "../comms/send-gate.mjs";
|
|
57
57
|
import { getHookBus } from "../hooks/bus.mjs";
|
|
58
|
+
import { recordOutbound } from "../comms/receipts.mjs";
|
|
58
59
|
|
|
59
60
|
// ---------------------------------------------------------------------------
|
|
60
61
|
// Internal helpers
|
|
@@ -73,6 +74,55 @@ function resolveOrg(cfg) {
|
|
|
73
74
|
return { base: c.base, token: c.token };
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Normalise a mentions list onto the shape hq actually validates.
|
|
79
|
+
*
|
|
80
|
+
* ── WHY THIS EXISTS ──
|
|
81
|
+
* hq's `sendMessageSchemaV1` declares
|
|
82
|
+
* `mentions: z.array(z.object({ memberId: string, offset?: number })).max(50)`
|
|
83
|
+
* (server/validation/message.ts#mentionSchema). This module sent
|
|
84
|
+
* `params.mentions.map(String)` — an array of BARE STRINGS. Zod rejects the
|
|
85
|
+
* whole object, so a send carrying mentions did not merely lose its tags: the
|
|
86
|
+
* ENTIRE `messaging.send` came back `BAD_REQUEST`. Tagging was not "missing"
|
|
87
|
+
* from the SDK, it was a landmine — the one call an agent would make to bring a
|
|
88
|
+
* colleague in was the one call guaranteed to fail.
|
|
89
|
+
*
|
|
90
|
+
* Accepts, in the wild: `"M-1"`, `{memberId:"M-1"}`, `{id:"M-1"}`,
|
|
91
|
+
* `{memberId:"M-1", offset:12}`. Drops empties and de-dupes on memberId, since
|
|
92
|
+
* mentioning the same person twice creates two notification rows for one ask.
|
|
93
|
+
*
|
|
94
|
+
* @param {any} mentions
|
|
95
|
+
* @returns {Array<{memberId:string, offset?:number}>}
|
|
96
|
+
*/
|
|
97
|
+
export function normaliseMentions(mentions) {
|
|
98
|
+
if (!Array.isArray(mentions)) return [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const m of mentions) {
|
|
102
|
+
let memberId = "";
|
|
103
|
+
let offset;
|
|
104
|
+
if (typeof m === "string" || typeof m === "number") {
|
|
105
|
+
memberId = String(m).trim();
|
|
106
|
+
} else if (m && typeof m === "object") {
|
|
107
|
+
memberId = String(m.memberId ?? m.id ?? m.member ?? "").trim();
|
|
108
|
+
if (Number.isInteger(m.offset) && m.offset >= 0) offset = m.offset;
|
|
109
|
+
}
|
|
110
|
+
if (!memberId || seen.has(memberId)) continue;
|
|
111
|
+
seen.add(memberId);
|
|
112
|
+
out.push(offset === undefined ? { memberId } : { memberId, offset });
|
|
113
|
+
}
|
|
114
|
+
return out.slice(0, 50);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Pull a channel id out of the several shapes hq's channel.* results use. */
|
|
118
|
+
function channelIdOf(result) {
|
|
119
|
+
if (!result || typeof result !== "object") return null;
|
|
120
|
+
const direct = result.channelId || result.id;
|
|
121
|
+
if (direct) return String(direct);
|
|
122
|
+
const nested = result.channel && (result.channel.id || result.channel.channelId);
|
|
123
|
+
return nested ? String(nested) : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
76
126
|
/** A best-effort agent id for ledger attribution (never throws). */
|
|
77
127
|
function agentIdOf(cfg) {
|
|
78
128
|
const n = (cfg && cfg.org && cfg.org.cohort) || {};
|
|
@@ -201,11 +251,14 @@ export async function sendMessage(params = {}, o = {}) {
|
|
|
201
251
|
// SDK was rejected with "channelId: Required" — no agent could post to Cohort
|
|
202
252
|
// at all. The legacy names are kept alongside for any older server that still
|
|
203
253
|
// reads them; the canonical ones are what hq validates.
|
|
254
|
+
const mentions = normaliseMentions(params.mentions);
|
|
204
255
|
const wire = {
|
|
205
256
|
channelId: channel,
|
|
206
257
|
channel,
|
|
207
258
|
body: text,
|
|
208
|
-
|
|
259
|
+
// `[{memberId}]`, never `["M-1"]` — see normaliseMentions for what the bare
|
|
260
|
+
// string cost.
|
|
261
|
+
...(mentions.length ? { mentions } : {}),
|
|
209
262
|
...(params.threadId != null ? { threadRootId: String(params.threadId), threadId: String(params.threadId) } : {}),
|
|
210
263
|
...(idempotencyId ? { idempotencyId, id: idempotencyId } : {}),
|
|
211
264
|
};
|
|
@@ -216,10 +269,20 @@ export async function sendMessage(params = {}, o = {}) {
|
|
|
216
269
|
fetchImpl: o.fetchImpl,
|
|
217
270
|
});
|
|
218
271
|
|
|
219
|
-
// (4) on success: observe-only afterSend + source="messaging" attribution
|
|
272
|
+
// (4) on success: observe-only afterSend + source="messaging" attribution +
|
|
273
|
+
// a DELIVERY RECEIPT. The receipt is what lets the daemon answer "did a human
|
|
274
|
+
// actually hear from us about this ask" with evidence instead of an exit code.
|
|
220
275
|
if (frame && frame.ok) {
|
|
276
|
+
recordOutbound({
|
|
277
|
+
service: "cohort",
|
|
278
|
+
channel,
|
|
279
|
+
kind: "reply",
|
|
280
|
+
via: "messaging.send",
|
|
281
|
+
chars: text.length,
|
|
282
|
+
agentRoot: o.agentRoot,
|
|
283
|
+
});
|
|
221
284
|
await emitAfterSend(
|
|
222
|
-
{ channel: "cohort", recipient: channel, text, mentions:
|
|
285
|
+
{ channel: "cohort", recipient: channel, text, mentions: mentions.map((m) => m.memberId), result: frame.result, source: "messaging" },
|
|
223
286
|
o
|
|
224
287
|
);
|
|
225
288
|
await attributeMessaging({ cfg: o.cfg, agentRoot: o.agentRoot, action: "messaging.send", channel }, o);
|
|
@@ -309,6 +372,165 @@ export async function listChannels(o = {}) {
|
|
|
309
372
|
return [];
|
|
310
373
|
}
|
|
311
374
|
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Rooms — open a DM, open a group, create a channel, add someone to one
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
//
|
|
379
|
+
// All four methods have been in the frozen protocol table since it was written
|
|
380
|
+
// (`channel.resolveOrCreateDm` / `.createConversation` / `.create` / `.addMember`,
|
|
381
|
+
// all on `messaging.write`). None of them had a helper here, so an SDK agent
|
|
382
|
+
// could reply where it was spoken to and nowhere else: it could not open a 1:1
|
|
383
|
+
// with someone who had not written to it first, could not put three people in a
|
|
384
|
+
// room, and could not pull a fourth into an existing one. "Bring the right
|
|
385
|
+
// people in" was unreachable not because the org lacked the verb but because
|
|
386
|
+
// this layer never bound it.
|
|
387
|
+
//
|
|
388
|
+
// Each returns `{ok, channelId, created, frame}` rather than a raw frame, since
|
|
389
|
+
// every caller's next move is `sendMessage({channel: channelId, …})` and the id
|
|
390
|
+
// arrives under three different keys depending on the handler.
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Resolve (or create) the deterministic 1:1 DM channel with a member.
|
|
394
|
+
* Idempotent by construction — hq derives the slug from the two member slugs,
|
|
395
|
+
* so a retry reuses the room instead of littering DMs.
|
|
396
|
+
*
|
|
397
|
+
* @param {object} params - { memberId }
|
|
398
|
+
* @param {object} o - { cfg, fetchImpl?, agentRoot? }
|
|
399
|
+
* @returns {Promise<{ok:boolean, channelId:string|null, created:boolean, frame:object}>}
|
|
400
|
+
*/
|
|
401
|
+
export async function openDm(params = {}, o = {}) {
|
|
402
|
+
const memberId = params.memberId != null ? String(params.memberId).trim() : "";
|
|
403
|
+
if (!memberId) {
|
|
404
|
+
const frame = errFrame("BAD_REQUEST", "openDm: missing memberId");
|
|
405
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
406
|
+
}
|
|
407
|
+
const org = resolveOrg(o.cfg);
|
|
408
|
+
if (!org) {
|
|
409
|
+
const frame = errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
|
|
410
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
411
|
+
}
|
|
412
|
+
const frame = await call("channel.resolveOrCreateDm", { memberId }, {
|
|
413
|
+
base: org.base, token: org.token, fetchImpl: o.fetchImpl,
|
|
414
|
+
});
|
|
415
|
+
const channelId = frame && frame.ok ? channelIdOf(frame.result) : null;
|
|
416
|
+
return {
|
|
417
|
+
ok: !!(frame && frame.ok && channelId),
|
|
418
|
+
channelId,
|
|
419
|
+
created: !!(frame && frame.ok && frame.result && frame.result.created === true),
|
|
420
|
+
frame,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Open a conversation with one or more people (channel.createConversation).
|
|
426
|
+
* hq collapses a single other person onto the deterministic 1:1 DM and mints a
|
|
427
|
+
* fresh GROUP_DM for two or more — so this is the right call for "get these
|
|
428
|
+
* three in a room" without the caller having to branch on the count.
|
|
429
|
+
*
|
|
430
|
+
* @param {object} params - { memberIds:string[], name? }
|
|
431
|
+
* @param {object} o - { cfg, fetchImpl?, idempotencyKey? }
|
|
432
|
+
* @returns {Promise<{ok:boolean, channelId:string|null, created:boolean, frame:object}>}
|
|
433
|
+
*/
|
|
434
|
+
export async function openConversation(params = {}, o = {}) {
|
|
435
|
+
const memberIds = Array.from(
|
|
436
|
+
new Set((Array.isArray(params.memberIds) ? params.memberIds : []).map((m) => String(m || "").trim()).filter(Boolean)),
|
|
437
|
+
);
|
|
438
|
+
if (!memberIds.length) {
|
|
439
|
+
const frame = errFrame("BAD_REQUEST", "openConversation: memberIds is empty");
|
|
440
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
441
|
+
}
|
|
442
|
+
const org = resolveOrg(o.cfg);
|
|
443
|
+
if (!org) {
|
|
444
|
+
const frame = errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
|
|
445
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
446
|
+
}
|
|
447
|
+
const wire = { memberIds, ...(params.name ? { name: String(params.name).slice(0, 120) } : {}) };
|
|
448
|
+
const frame = await call("channel.createConversation", wire, {
|
|
449
|
+
base: org.base, token: org.token, idempotencyKey: o.idempotencyKey, fetchImpl: o.fetchImpl,
|
|
450
|
+
});
|
|
451
|
+
const channelId = frame && frame.ok ? channelIdOf(frame.result) : null;
|
|
452
|
+
return {
|
|
453
|
+
ok: !!(frame && frame.ok && channelId),
|
|
454
|
+
channelId,
|
|
455
|
+
created: !!(frame && frame.ok && frame.result && frame.result.created !== false),
|
|
456
|
+
frame,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Create a named org channel (channel.create). This is the MOST expensive room
|
|
462
|
+
* an agent can make — it is persistent, it appears in everyone's sidebar, and
|
|
463
|
+
* nobody can un-see it — so callers should reach for `openConversation` first
|
|
464
|
+
* and only create a channel when the work is durable and the audience standing.
|
|
465
|
+
*
|
|
466
|
+
* @param {object} params - { slug, name, kind?, topic?, members? }
|
|
467
|
+
* @param {object} o - { cfg, fetchImpl?, idempotencyKey? }
|
|
468
|
+
* @returns {Promise<{ok:boolean, channelId:string|null, created:boolean, frame:object}>}
|
|
469
|
+
*/
|
|
470
|
+
export async function createChannel(params = {}, o = {}) {
|
|
471
|
+
const slug = params.slug != null ? String(params.slug).trim().toLowerCase() : "";
|
|
472
|
+
const name = params.name != null ? String(params.name).trim() : "";
|
|
473
|
+
if (!slug || !name) {
|
|
474
|
+
const frame = errFrame("BAD_REQUEST", "createChannel: slug and name are required");
|
|
475
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
476
|
+
}
|
|
477
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
|
478
|
+
// hq's `channelSlugSchema` refuses anything else; say so here rather than
|
|
479
|
+
// spending a round trip to be told.
|
|
480
|
+
const frame = errFrame("BAD_REQUEST", `createChannel: slug "${slug}" must be lowercase alphanumerics and hyphens`);
|
|
481
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
482
|
+
}
|
|
483
|
+
const org = resolveOrg(o.cfg);
|
|
484
|
+
if (!org) {
|
|
485
|
+
const frame = errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
|
|
486
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
487
|
+
}
|
|
488
|
+
const wire = {
|
|
489
|
+
slug,
|
|
490
|
+
name: name.slice(0, 120),
|
|
491
|
+
kind: params.kind ? String(params.kind).toUpperCase() : "PRIVATE",
|
|
492
|
+
...(params.topic ? { topic: String(params.topic).slice(0, 500) } : {}),
|
|
493
|
+
...(Array.isArray(params.members) && params.members.length
|
|
494
|
+
? { members: params.members.map((m) => String(m)).filter(Boolean) }
|
|
495
|
+
: {}),
|
|
496
|
+
};
|
|
497
|
+
const frame = await call("channel.create", wire, {
|
|
498
|
+
base: org.base, token: org.token, idempotencyKey: o.idempotencyKey, fetchImpl: o.fetchImpl,
|
|
499
|
+
});
|
|
500
|
+
const channelId = frame && frame.ok ? channelIdOf(frame.result) : null;
|
|
501
|
+
return { ok: !!(frame && frame.ok && channelId), channelId, created: !!(frame && frame.ok), frame };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Add a member to a channel (channel.addMember). Idempotent — hq returns the
|
|
506
|
+
* existing row with `added:false` rather than erroring, so a re-invite is a
|
|
507
|
+
* no-op. The acting agent must already be in the channel (hq default-denies
|
|
508
|
+
* inviting into a room you are not in).
|
|
509
|
+
*
|
|
510
|
+
* @param {object} params - { channelId, memberId }
|
|
511
|
+
* @param {object} o - { cfg, fetchImpl?, idempotencyKey? }
|
|
512
|
+
* @returns {Promise<{ok:boolean, added:boolean, frame:object}>}
|
|
513
|
+
*/
|
|
514
|
+
export async function addChannelMember(params = {}, o = {}) {
|
|
515
|
+
const channelId = params.channelId != null ? String(params.channelId).trim() : "";
|
|
516
|
+
const memberId = params.memberId != null ? String(params.memberId).trim() : "";
|
|
517
|
+
if (!channelId || !memberId) {
|
|
518
|
+
return { ok: false, added: false, frame: errFrame("BAD_REQUEST", "addChannelMember: channelId and memberId are required") };
|
|
519
|
+
}
|
|
520
|
+
const org = resolveOrg(o.cfg);
|
|
521
|
+
if (!org) {
|
|
522
|
+
return { ok: false, added: false, frame: errFrame("BAD_REQUEST", "org messaging disabled or unconfigured") };
|
|
523
|
+
}
|
|
524
|
+
const frame = await call("channel.addMember", { channelId, memberId }, {
|
|
525
|
+
base: org.base, token: org.token, idempotencyKey: o.idempotencyKey, fetchImpl: o.fetchImpl,
|
|
526
|
+
});
|
|
527
|
+
return {
|
|
528
|
+
ok: !!(frame && frame.ok),
|
|
529
|
+
added: !!(frame && frame.ok && frame.result && frame.result.added !== false),
|
|
530
|
+
frame,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
312
534
|
// ---------------------------------------------------------------------------
|
|
313
535
|
// Calling — start / join
|
|
314
536
|
// ---------------------------------------------------------------------------
|
|
@@ -660,6 +882,11 @@ export default {
|
|
|
660
882
|
reactMessage,
|
|
661
883
|
fetchHistory,
|
|
662
884
|
listChannels,
|
|
885
|
+
openDm,
|
|
886
|
+
openConversation,
|
|
887
|
+
createChannel,
|
|
888
|
+
addChannelMember,
|
|
889
|
+
normaliseMentions,
|
|
663
890
|
initiateCall,
|
|
664
891
|
joinCall,
|
|
665
892
|
pullInbound,
|
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
reactMessage,
|
|
16
16
|
fetchHistory,
|
|
17
17
|
listChannels,
|
|
18
|
+
openDm,
|
|
19
|
+
openConversation,
|
|
20
|
+
createChannel,
|
|
21
|
+
addChannelMember,
|
|
18
22
|
initiateCall,
|
|
19
23
|
joinCall,
|
|
20
24
|
pullInbound,
|
|
@@ -93,10 +97,41 @@ test("sendMessage: a clean message posts to /v1/messaging.send with Bearer + pro
|
|
|
93
97
|
// wire shape
|
|
94
98
|
assert.equal(f.calls[0].body.channel, "C-eng");
|
|
95
99
|
assert.equal(f.calls[0].body.body, "Deploy is green on staging.");
|
|
96
|
-
|
|
100
|
+
// `[{memberId}]`, NOT `["agent-2"]`. hq's mentionSchema is an object schema,
|
|
101
|
+
// so the bare-string form this used to assert did not merely lose the tag — it
|
|
102
|
+
// failed zod and 400'd the entire send. The one call an agent makes to bring a
|
|
103
|
+
// colleague in was the one call guaranteed to fail.
|
|
104
|
+
assert.deepEqual(f.calls[0].body.mentions, [{ memberId: "agent-2" }]);
|
|
97
105
|
assert.equal(f.calls[0].body.id, "msg-xyz");
|
|
98
106
|
});
|
|
99
107
|
|
|
108
|
+
test("sendMessage: mentions accept strings or objects, de-dupe, and keep offsets", async () => {
|
|
109
|
+
const f = fakeFetch(() => ({ body: { ok: true, result: { id: "m2b" } } }));
|
|
110
|
+
await sendMessage(
|
|
111
|
+
{
|
|
112
|
+
channel: "C-eng",
|
|
113
|
+
body: "hi",
|
|
114
|
+
mentions: ["agent-2", { memberId: "agent-2" }, { id: "agent-3" }, { memberId: "agent-4", offset: 7 }, "", null],
|
|
115
|
+
idempotencyId: "msg-m",
|
|
116
|
+
},
|
|
117
|
+
{ cfg: CFG, fetchImpl: f, gateImpl: allowGate, hookBus: captureBus(), ledgerImpl: captureLedger() },
|
|
118
|
+
);
|
|
119
|
+
assert.deepEqual(f.calls[0].body.mentions, [
|
|
120
|
+
{ memberId: "agent-2" },
|
|
121
|
+
{ memberId: "agent-3" },
|
|
122
|
+
{ memberId: "agent-4", offset: 7 },
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("sendMessage: no mentions means the key is absent, not an empty array", async () => {
|
|
127
|
+
const f = fakeFetch(() => ({ body: { ok: true, result: { id: "m2c" } } }));
|
|
128
|
+
await sendMessage(
|
|
129
|
+
{ channel: "C-eng", body: "hi", mentions: [] },
|
|
130
|
+
{ cfg: CFG, fetchImpl: f, gateImpl: allowGate, hookBus: captureBus(), ledgerImpl: captureLedger() },
|
|
131
|
+
);
|
|
132
|
+
assert.equal("mentions" in f.calls[0].body, false);
|
|
133
|
+
});
|
|
134
|
+
|
|
100
135
|
test("sendMessage: emits afterSend + attributes a source='messaging' ledger row on success", async () => {
|
|
101
136
|
const f = fakeFetch(() => ({ body: { ok: true, result: { id: "m3" } } }));
|
|
102
137
|
const ledger = captureLedger();
|
|
@@ -353,3 +388,77 @@ test("isDirectedAtAgent: an explicit mentions[] still wins when a producer suppl
|
|
|
353
388
|
// …and someone else's mention in the same space is still not ours.
|
|
354
389
|
assert.equal(isDirectedAtAgent(ev, "agent-other"), false);
|
|
355
390
|
});
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Rooms — the four verbs the SDK had in its protocol table and could not call
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
test("openDm resolves the 1:1 channel and hands back the id the send needs", async () => {
|
|
397
|
+
const f = fakeFetch(() => ({ body: { ok: true, result: { channelId: "C-dm-1", created: true } } }));
|
|
398
|
+
const r = await openDm({ memberId: "M-2" }, { cfg: CFG, fetchImpl: f });
|
|
399
|
+
assert.equal(r.ok, true);
|
|
400
|
+
assert.equal(r.channelId, "C-dm-1");
|
|
401
|
+
assert.equal(r.created, true);
|
|
402
|
+
assert.match(f.calls[0].url, /\/v1\/channel\.resolveOrCreateDm$/);
|
|
403
|
+
assert.deepEqual(f.calls[0].body, { memberId: "M-2" });
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("openDm reads the channel id out of whichever shape the handler returns", async () => {
|
|
407
|
+
for (const result of [{ id: "C-x" }, { channelId: "C-x" }, { channel: { id: "C-x" } }]) {
|
|
408
|
+
const r = await openDm({ memberId: "M-2" }, { cfg: CFG, fetchImpl: fakeFetch(() => ({ body: { ok: true, result } })) });
|
|
409
|
+
assert.equal(r.channelId, "C-x", JSON.stringify(result));
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("openConversation de-dupes the member list and sends hq's `memberIds`", async () => {
|
|
414
|
+
const f = fakeFetch(() => ({ body: { ok: true, result: { id: "C-gdm" } } }));
|
|
415
|
+
const r = await openConversation({ memberIds: ["M-2", "M-3", "M-2", ""], name: "Vendor contract" }, { cfg: CFG, fetchImpl: f });
|
|
416
|
+
assert.equal(r.ok, true);
|
|
417
|
+
assert.equal(r.channelId, "C-gdm");
|
|
418
|
+
assert.match(f.calls[0].url, /\/v1\/channel\.createConversation$/);
|
|
419
|
+
assert.deepEqual(f.calls[0].body.memberIds, ["M-2", "M-3"]);
|
|
420
|
+
assert.equal(f.calls[0].body.name, "Vendor contract");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("openConversation with nobody in it never reaches the wire", async () => {
|
|
424
|
+
const f = fakeFetch(() => ({ body: { ok: true, result: {} } }));
|
|
425
|
+
const r = await openConversation({ memberIds: [] }, { cfg: CFG, fetchImpl: f });
|
|
426
|
+
assert.equal(r.ok, false);
|
|
427
|
+
assert.equal(f.calls.length, 0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("createChannel refuses a slug hq would reject, before spending a round trip", async () => {
|
|
431
|
+
const f = fakeFetch(() => ({ body: { ok: true, result: { id: "C-1" } } }));
|
|
432
|
+
const bad = await createChannel({ slug: "Vendor Contract!", name: "Vendor" }, { cfg: CFG, fetchImpl: f });
|
|
433
|
+
assert.equal(bad.ok, false);
|
|
434
|
+
assert.match(bad.frame.error.message, /lowercase alphanumerics and hyphens/);
|
|
435
|
+
assert.equal(f.calls.length, 0);
|
|
436
|
+
|
|
437
|
+
const good = await createChannel({ slug: "vendor-contract", name: "Vendor", members: ["M-2"] }, { cfg: CFG, fetchImpl: f });
|
|
438
|
+
assert.equal(good.ok, true);
|
|
439
|
+
assert.equal(f.calls[0].body.kind, "PRIVATE", "a channel an agent makes is private by default");
|
|
440
|
+
assert.deepEqual(f.calls[0].body.members, ["M-2"]);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("addChannelMember reports hq's idempotent re-add as added:false, not as a failure", async () => {
|
|
444
|
+
const f = fakeFetch(() => ({ body: { ok: true, result: { channelId: "C-1", memberId: "M-2", added: false } } }));
|
|
445
|
+
const r = await addChannelMember({ channelId: "C-1", memberId: "M-2" }, { cfg: CFG, fetchImpl: f });
|
|
446
|
+
assert.equal(r.ok, true);
|
|
447
|
+
assert.equal(r.added, false);
|
|
448
|
+
assert.match(f.calls[0].url, /\/v1\/channel\.addMember$/);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("every room verb fails open to an error frame when the org is unconfigured", async () => {
|
|
452
|
+
const off = { org: { cohort: { enabled: false } } };
|
|
453
|
+
for (const [name, fn, args] of [
|
|
454
|
+
["openDm", openDm, { memberId: "M-2" }],
|
|
455
|
+
["openConversation", openConversation, { memberIds: ["M-2"] }],
|
|
456
|
+
["createChannel", createChannel, { slug: "x", name: "X" }],
|
|
457
|
+
["addChannelMember", addChannelMember, { channelId: "C", memberId: "M-2" }],
|
|
458
|
+
]) {
|
|
459
|
+
const r = await fn(args, { cfg: off });
|
|
460
|
+
assert.equal(r.ok, false, name);
|
|
461
|
+
assert.equal(r.frame.ok, false, name);
|
|
462
|
+
assert.ok(r.frame.error.message, `${name} must say why`);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
@@ -131,6 +131,54 @@ function firstString(...vals) {
|
|
|
131
131
|
return "";
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Coerce `mentions` onto the OBJECT shape hq validates.
|
|
136
|
+
*
|
|
137
|
+
* hq's `mentionSchema` is `z.object({memberId, offset?})` and `mentions` is an
|
|
138
|
+
* array of it (server/validation/message.ts). Every SDK call site wrote the
|
|
139
|
+
* obvious thing — an array of member id STRINGS — and zod rejects the array, so
|
|
140
|
+
* the entire `messaging.send` came back BAD_REQUEST. Not "the tags were lost":
|
|
141
|
+
* the whole message was refused. Tagging somebody is the one action an agent
|
|
142
|
+
* takes to make a colleague accountable for answering, and it was the one action
|
|
143
|
+
* guaranteed to fail.
|
|
144
|
+
*
|
|
145
|
+
* It belongs HERE, in the one contract every plane goes through, rather than in
|
|
146
|
+
* any single helper: the curated `messaging_send` tool, the `org_rpc` escape
|
|
147
|
+
* hatch, the ui-parity wrappers and `messaging.sendMessage` all reach the wire
|
|
148
|
+
* through `client.call`, and fixing one of them would have left the rest armed.
|
|
149
|
+
*
|
|
150
|
+
* Idempotent: already-shaped input passes through untouched.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} params
|
|
153
|
+
* @returns {object}
|
|
154
|
+
*/
|
|
155
|
+
export function normaliseMentionParams(params) {
|
|
156
|
+
const p = isObj(params) ? params : {};
|
|
157
|
+
if (!Array.isArray(p.mentions)) return p;
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
const mentions = [];
|
|
160
|
+
for (const m of p.mentions) {
|
|
161
|
+
let memberId = "";
|
|
162
|
+
let offset;
|
|
163
|
+
if (typeof m === "string" || typeof m === "number") {
|
|
164
|
+
memberId = String(m).trim();
|
|
165
|
+
} else if (isObj(m)) {
|
|
166
|
+
memberId = String(m.memberId ?? m.id ?? m.member ?? "").trim();
|
|
167
|
+
if (Number.isInteger(m.offset) && m.offset >= 0) offset = m.offset;
|
|
168
|
+
}
|
|
169
|
+
if (!memberId || seen.has(memberId)) continue;
|
|
170
|
+
seen.add(memberId);
|
|
171
|
+
mentions.push(offset === undefined ? { memberId } : { memberId, offset });
|
|
172
|
+
}
|
|
173
|
+
// An EMPTY list is dropped rather than sent: hq is happy with `[]`, but an
|
|
174
|
+
// absent key states "no tags" without asserting an empty relation.
|
|
175
|
+
if (!mentions.length) {
|
|
176
|
+
const { mentions: _drop, ...rest } = p;
|
|
177
|
+
return rest;
|
|
178
|
+
}
|
|
179
|
+
return { ...p, mentions: mentions.slice(0, 50) };
|
|
180
|
+
}
|
|
181
|
+
|
|
134
182
|
/**
|
|
135
183
|
* Project a rich maestro self-entry onto hq's `.strict()` registerSchema
|
|
136
184
|
* ({displayName, archetype, humanSponsor, card}). Everything the server does not
|
|
@@ -255,7 +303,11 @@ export const PARAM_CONTRACT = {
|
|
|
255
303
|
},
|
|
256
304
|
mint: ["idempotencyId"],
|
|
257
305
|
required: ["channelId", "body", "idempotencyId"],
|
|
258
|
-
|
|
306
|
+
transform: normaliseMentionParams,
|
|
307
|
+
note:
|
|
308
|
+
"hq reads idempotencyId (NOT clientMsgId) — sendMessageSchemaV1. `mentions` is an array of " +
|
|
309
|
+
"OBJECTS ({memberId, offset?}), not of id strings: the string form fails zod and 400s the " +
|
|
310
|
+
"whole send, so the transform coerces it.",
|
|
259
311
|
},
|
|
260
312
|
"messaging.history": {
|
|
261
313
|
alias: { channel: "channelId", cursor: "before", since: "after" },
|
|
@@ -274,7 +326,9 @@ export const PARAM_CONTRACT = {
|
|
|
274
326
|
note: "pagination anchors are MESSAGE IDS (before=older, after=newer), not timestamps.",
|
|
275
327
|
},
|
|
276
328
|
"messaging.react": { alias: { channel: "channelId" }, required: ["messageId", "emoji"] },
|
|
277
|
-
|
|
329
|
+
// Same object-shaped `mentions` as `messaging.send` (editMessageSchema reuses
|
|
330
|
+
// mentionSchema), so it carries the same landmine and the same fix.
|
|
331
|
+
"messaging.edit": { required: ["messageId", "body"], transform: normaliseMentionParams },
|
|
278
332
|
|
|
279
333
|
// ── calling ──────────────────────────────────────────────────────────────
|
|
280
334
|
"calling.start": {
|
|
@@ -449,3 +449,29 @@ test("GUARD: enum-valued tool params list exactly hq's vocabulary", () => {
|
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
451
|
});
|
|
452
|
+
|
|
453
|
+
test("messaging.send/edit: mentions are coerced to hq's object shape at the ONE chokepoint", () => {
|
|
454
|
+
const sent = normalizeParams("messaging.send", {
|
|
455
|
+
channelId: "C-1",
|
|
456
|
+
body: "b",
|
|
457
|
+
idempotencyId: "i",
|
|
458
|
+
mentions: ["M-2", { memberId: "M-2" }, { id: "M-3" }, { memberId: "M-4", offset: 3 }, "", null],
|
|
459
|
+
});
|
|
460
|
+
assert.deepEqual(sent.params.mentions, [
|
|
461
|
+
{ memberId: "M-2" },
|
|
462
|
+
{ memberId: "M-3" },
|
|
463
|
+
{ memberId: "M-4", offset: 3 },
|
|
464
|
+
]);
|
|
465
|
+
|
|
466
|
+
// Idempotent: applying the contract twice must not re-wrap.
|
|
467
|
+
const twice = normalizeParams("messaging.send", sent.params);
|
|
468
|
+
assert.deepEqual(twice.params.mentions, sent.params.mentions);
|
|
469
|
+
|
|
470
|
+
// An empty list states nothing rather than asserting an empty relation.
|
|
471
|
+
const none = normalizeParams("messaging.send", { channelId: "C", body: "b", idempotencyId: "i", mentions: [] });
|
|
472
|
+
assert.equal("mentions" in none.params, false);
|
|
473
|
+
|
|
474
|
+
// messaging.edit reuses hq's mentionSchema, so it gets the same coercion.
|
|
475
|
+
const edited = normalizeParams("messaging.edit", { messageId: "m1", body: "b", mentions: ["M-9"] });
|
|
476
|
+
assert.deepEqual(edited.params.mentions, [{ memberId: "M-9" }]);
|
|
477
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
358b95ed6a48faeffa78b7ca717a909905e8ff4e7b75278edf274a0acdaf5dd6
|
package/lib/org/protocol.mjs
CHANGED
|
@@ -485,6 +485,11 @@ export const METHODS = Object.freeze({
|
|
|
485
485
|
"board.removeTaskAttachment": { family: "board", scope: "board.write", sideEffecting: true },
|
|
486
486
|
"board.taskComments": { family: "board", scope: "board.read", sideEffecting: false },
|
|
487
487
|
"board.taskAttachments": { family: "board", scope: "board.read", sideEffecting: false },
|
|
488
|
+
// board.track — ONE step of an agent's work made visible: find-or-open the row
|
|
489
|
+
// for an ask, move its column, comment progress, attach what it produced, and
|
|
490
|
+
// TAG the requester. Idempotent on (askKey, stage): the same step replayed
|
|
491
|
+
// creates no second row, no duplicate comment and no second notification.
|
|
492
|
+
"board.track": { family: "board", scope: "board.write", sideEffecting: true, idempotent: true },
|
|
488
493
|
// --- invitation ---
|
|
489
494
|
"invitation.create": { family: "invitation", scope: "admin", sideEffecting: true, idempotent: true },
|
|
490
495
|
"invitation.revoke": { family: "invitation", scope: "admin", sideEffecting: true },
|
|
@@ -169,8 +169,14 @@ test("protocol: frozen family + method counts (additive evolution guard)", () =>
|
|
|
169
169
|
// escalation.ask/answer, memory.recall, preference.upsert/list (6 reverse-
|
|
170
170
|
// parity methods), the 9 subagent.* registry methods, agent.wait, the 11
|
|
171
171
|
// mandate.* spine methods, and board.requestReview/resolveReview → 460.
|
|
172
|
+
// The 2026-08 work-visibility pass adds 1: board.track — ONE step of an
|
|
173
|
+
// agent's work made visible (find-or-open the row for an ask, move its
|
|
174
|
+
// column, comment progress, attach output, tag the requester). It is the
|
|
175
|
+
// single door BOTH planes use: hq's in-process responder calls the same
|
|
176
|
+
// `server/work/ledger.ts` directly, the daemon reaches it over this wire, so
|
|
177
|
+
// the threshold and the idempotency cannot fork → 461.
|
|
172
178
|
// the descriptors; the checksum (read at runtime) is the primary drift guard.
|
|
173
|
-
assert.equal(Object.keys(METHODS).length,
|
|
179
|
+
assert.equal(Object.keys(METHODS).length, 461, "method count");
|
|
174
180
|
});
|
|
175
181
|
|
|
176
182
|
test("protocol SP3: messaging + calling families/methods/scopes", async () => {
|