@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.
Files changed (106) hide show
  1. package/bin/maestro.mjs +185 -88
  2. package/bin/maestro.test.mjs +175 -48
  3. package/docs/runbooks/backup-restore.md +65 -33
  4. package/framework-features.json +4 -4
  5. package/lib/backup/policy.mjs +710 -0
  6. package/lib/backup/policy.test.mjs +305 -0
  7. package/lib/budget-escalate.mjs +133 -0
  8. package/lib/budget-escalate.test.mjs +232 -0
  9. package/lib/budget-guard.envelope.test.mjs +476 -0
  10. package/lib/budget-guard.mjs +853 -75
  11. package/lib/budget-guard.test.mjs +91 -42
  12. package/lib/cadences.mjs +33 -0
  13. package/lib/channels/orgmail/adapter.mjs +88 -3
  14. package/lib/channels/orgmail/adapter.test.mjs +137 -0
  15. package/lib/channels/repeat-suppressor.mjs +198 -0
  16. package/lib/channels/repeat-suppressor.test.mjs +134 -0
  17. package/lib/comms/receipts.mjs +297 -0
  18. package/lib/cost/ledger-row.mjs +333 -0
  19. package/lib/cost/ledger-row.test.mjs +183 -0
  20. package/lib/execution/drive.mjs +28 -1
  21. package/lib/execution/effects.mjs +191 -12
  22. package/lib/execution/effects.test.mjs +50 -11
  23. package/lib/goals/admission.mjs +13 -1
  24. package/lib/goals/admission.test.mjs +26 -1
  25. package/lib/goals/loop.mjs +13 -0
  26. package/lib/kpi-sensors.test.mjs +3 -0
  27. package/lib/mandate/cache.mjs +13 -5
  28. package/lib/mandate/derive.mjs +146 -21
  29. package/lib/mandate/derive.test.mjs +50 -6
  30. package/lib/mandate/model.mjs +32 -4
  31. package/lib/mandate/refresh.test.mjs +16 -2
  32. package/lib/mcp/server.test.mjs +12 -3
  33. package/lib/model-router/economics.mjs +107 -76
  34. package/lib/model-router/economics.test.mjs +64 -46
  35. package/lib/model-router/integration-coverage.test.mjs +39 -37
  36. package/lib/model-router/ledger.mjs +75 -22
  37. package/lib/model-router/ledger.test.mjs +35 -2
  38. package/lib/org/client.mjs +14 -0
  39. package/lib/org/cost-sync.mjs +16 -2
  40. package/lib/org/doctor.mjs +62 -1
  41. package/lib/org/doctor.test.mjs +36 -3
  42. package/lib/org/email-remedy.mjs +49 -0
  43. package/lib/org/engagement-ledger.mjs +376 -0
  44. package/lib/org/engagement-ledger.test.mjs +112 -0
  45. package/lib/org/engagement.mjs +1056 -0
  46. package/lib/org/engagement.test.mjs +739 -0
  47. package/lib/org/messaging.mjs +230 -3
  48. package/lib/org/messaging.test.mjs +110 -1
  49. package/lib/org/param-contract.mjs +56 -2
  50. package/lib/org/param-contract.test.mjs +26 -0
  51. package/lib/org/protocol.checksum +1 -1
  52. package/lib/org/protocol.mjs +5 -0
  53. package/lib/org/protocol.test.mjs +7 -1
  54. package/lib/org/tool-surface.mjs +506 -10
  55. package/lib/org/tool-surface.test.mjs +191 -7
  56. package/lib/org/ui-parity.mjs +333 -6
  57. package/lib/org/ui-parity.test.mjs +96 -3
  58. package/lib/org/work-ledger.mjs +241 -0
  59. package/lib/org/work-ledger.test.mjs +237 -0
  60. package/lib/plan/adoption-e2e.test.mjs +366 -0
  61. package/lib/plan/budget-enforcement.test.mjs +400 -0
  62. package/lib/plan/budget-runtime.mjs +215 -0
  63. package/lib/plan/compile.mjs +201 -5
  64. package/lib/plan/compile.test.mjs +19 -5
  65. package/lib/plan/emit.mjs +8 -0
  66. package/lib/plan/emit.test.mjs +18 -0
  67. package/lib/resource-governor.mjs +58 -12
  68. package/lib/resource-governor.test.mjs +41 -1
  69. package/lib/security/audit-engine.mjs +45 -8
  70. package/lib/security/audit-engine.test.mjs +35 -0
  71. package/lib/setup/enroll-from-cohort.mjs +14 -1
  72. package/lib/setup/sections/mandate.mjs +48 -7
  73. package/lib/setup/sections/mandate.test.mjs +17 -2
  74. package/lib/setup/sections/orgmail.mjs +10 -2
  75. package/lib/setup/state.mjs +83 -2
  76. package/lib/telemetry/collect.mjs +360 -20
  77. package/lib/telemetry/collect.test.mjs +266 -0
  78. package/package.json +1 -1
  79. package/scripts/cost/track-claude-usage.mjs +207 -48
  80. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  81. package/scripts/daemon/agent-daemon.mjs +315 -17
  82. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  83. package/scripts/daemon/assurance.mjs +944 -0
  84. package/scripts/daemon/assurance.test.mjs +668 -0
  85. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  86. package/scripts/daemon/cadence-consumer.mjs +147 -9
  87. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  88. package/scripts/daemon/cadence-handlers.mjs +158 -0
  89. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  90. package/scripts/daemon/deliver.mjs +314 -0
  91. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  92. package/scripts/daemon/dispatcher.mjs +64 -6
  93. package/scripts/daemon/responder-cost.test.mjs +68 -0
  94. package/scripts/daemon/responder.mjs +351 -298
  95. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  96. package/scripts/maintenance/backup-run.mjs +415 -0
  97. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  98. package/scripts/org/send-orgmail.mjs +16 -0
  99. package/scripts/record-receipt.sh +63 -0
  100. package/scripts/restore-from-backup.sh +14 -3
  101. package/scripts/restore-from-backup.test.mjs +8 -5
  102. package/scripts/send-email-threaded.py +47 -0
  103. package/scripts/send-sms.sh +4 -0
  104. package/scripts/send-whatsapp.sh +4 -0
  105. package/scripts/setup/init-backup.mjs +93 -38
  106. package/scripts/slack-send.sh +12 -0
@@ -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
- ...(Array.isArray(params.mentions) && params.mentions.length ? { mentions: params.mentions.map(String) } : {}),
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: wire.mentions || [], result: frame.result, source: "messaging" },
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
- assert.deepEqual(f.calls[0].body.mentions, ["agent-2"]);
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
- note: "hq reads idempotencyId (NOT clientMsgId) — sendMessageSchemaV1.",
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
- "messaging.edit": { required: ["messageId", "body"] },
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
- 1946d98145f4c1e56d80e58c37d9b54d5ea8d88c7d30251a694484418327dddf
1
+ 358b95ed6a48faeffa78b7ca717a909905e8ff4e7b75278edf274a0acdaf5dd6
@@ -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, 460, "method count");
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 () => {