@cohortapp/agent-sdk 2.3.2 → 2.4.1

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 (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +105 -17
  97. package/lib/setup/enroll-from-cohort.test.mjs +68 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,341 @@
1
+ /**
2
+ * lib/org/typing.mjs — the agent-side "is composing…" indicator for Cohort.
3
+ *
4
+ * ## The gap this closes
5
+ *
6
+ * hq's in-process responder raises a typing indicator around every reply it
7
+ * composes (`llm-responder/dispatch.ts` withTyping). An SDK-driven agent could
8
+ * not: `pushTyping` is server-only and in-process, and the only external writer
9
+ * was the browser-session poll route. hq now also STANDS DOWN for any seat whose
10
+ * daemon beat inside 100s (`llm-responder/sdk-driven.ts`), so on exactly the
11
+ * conversations a real agent owns, the human saw nothing at all while the agent
12
+ * thought — for the tens of seconds a `claude --print` session takes. That reads
13
+ * as "the agent is broken", not "the agent is working".
14
+ *
15
+ * hq gained `messaging.typing` for this: a write-less RPC that publishes one
16
+ * ephemeral frame and returns the cadence to re-assert at. This module is the
17
+ * agent half.
18
+ *
19
+ * ## Shape: an adapter, not a new pipeline
20
+ *
21
+ * The daemon ALREADY starts/stops a typing heartbeat around every inbox-driven
22
+ * session (`scripts/daemon/dispatcher.mjs` → `typing-registry.startTyping(item)`
23
+ * on spawn, `stopTyping(item)` on close/error). That registry keys adapters by
24
+ * service name, and every channel adapter implements the same two methods from
25
+ * `lib/channels/base-adapter.mjs`:
26
+ *
27
+ * startTypingHeartbeat(source) -> key
28
+ * stopTypingHeartbeat(sourceOrKey)
29
+ *
30
+ * So the whole feature is one more adapter under the service name `cohort`,
31
+ * with the SAME interface — no new lifecycle, no new call sites, nothing to keep
32
+ * in sync. `sourceFromItem` (typing-registry) already yields
33
+ * `{channel:"cohort", chatId:<hq channelId>, threadRef:<threadRootId>}` for a
34
+ * cohort inbox item, which is exactly this method's params.
35
+ *
36
+ * ## Cadence
37
+ *
38
+ * The browser drops a typist ~5s after its last frame, so a long session must
39
+ * re-assert. We do NOT hardcode that: the first successful call returns
40
+ * `refreshAfterMs` and the heartbeat re-times itself to the server's answer
41
+ * (bounded by {@link MIN_INTERVAL_MS}/{@link MAX_INTERVAL_MS} so a bad server
42
+ * value cannot turn this into a hot loop or a dead indicator). Until an answer
43
+ * arrives we beat at {@link DEFAULT_INTERVAL_MS}.
44
+ *
45
+ * ## Fail-open, never silent
46
+ *
47
+ * Typing is cosmetic; nothing here may ever throw into the session path. Every
48
+ * failure resolves to `{ok:false, …}` and is LOGGED — but throttled per key
49
+ * ({@link LOG_THROTTLE_MS}), because an unreachable server would otherwise emit
50
+ * a line every 3s for the life of the session. Silent fail-open is what hid
51
+ * every previous bug in this pipeline; a 3s-interval logspam would hide the next
52
+ * one just as well.
53
+ *
54
+ * Node builtins only. ESM. `fetchImpl`/timer injection everywhere for tests.
55
+ *
56
+ * @module lib/org/typing
57
+ */
58
+
59
+ "use strict";
60
+
61
+ import { resolveAgentRoot } from "../agent-root.mjs";
62
+ import { call, configFromAgent, isEnabled, loadOrgConfig } from "./client.mjs";
63
+
64
+ /** Beat cadence used until the server states its own `refreshAfterMs`. */
65
+ export const DEFAULT_INTERVAL_MS = 3_000;
66
+ /** Floor on the server-advised cadence — never beat faster than this. */
67
+ export const MIN_INTERVAL_MS = 1_000;
68
+ /** Ceiling on the server-advised cadence — beyond this the indicator blinks. */
69
+ export const MAX_INTERVAL_MS = 15_000;
70
+ /** One failure line per key per this window (see the header). */
71
+ export const LOG_THROTTLE_MS = 60_000;
72
+ /** org.yaml is re-read at most this often (it changes ~never). */
73
+ const CONFIG_TTL_MS = 60_000;
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Config (cached — a 3s heartbeat must not stat + parse YAML every beat)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ let _cfg = null;
80
+ let _cfgAt = 0;
81
+ let _cfgRoot = "";
82
+
83
+ /** Drop the cached org config (tests / after re-enrolment). */
84
+ export function resetTypingConfigCache() {
85
+ _cfg = null;
86
+ _cfgAt = 0;
87
+ _cfgRoot = "";
88
+ }
89
+
90
+ /**
91
+ * Resolve the agent's org config, cached for {@link CONFIG_TTL_MS}.
92
+ * @param {object} opts - { cfg?, agentRoot?, now? }
93
+ * @returns {object} the parsed org config ({} when un-enrolled)
94
+ */
95
+ function resolveCfg(opts = {}) {
96
+ if (opts.cfg) return opts.cfg;
97
+ const root = resolveAgentRoot(opts.agentRoot);
98
+ const now = typeof opts.now === "number" ? opts.now : Date.now();
99
+ if (_cfg && _cfgRoot === root && now - _cfgAt < CONFIG_TTL_MS) return _cfg;
100
+ _cfg = loadOrgConfig(root) || {};
101
+ _cfgAt = now;
102
+ _cfgRoot = root;
103
+ return _cfg;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // The single RPC
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Publish one typing transition for this agent's seat.
112
+ *
113
+ * @param {object} o - {
114
+ * channelId, threadRootId?, state? (default true),
115
+ * cfg?, agentRoot?, fetchImpl?
116
+ * }
117
+ * @returns {Promise<{ok:boolean, skipped?:boolean, refreshAfterMs?:number,
118
+ * scopeKey?:string, error?:object}>}
119
+ */
120
+ export async function emitTyping(o = {}) {
121
+ const channelId = o.channelId ? String(o.channelId) : "";
122
+ if (!channelId) {
123
+ return { ok: false, skipped: true, error: { code: "BAD_REQUEST", message: "missing channelId" } };
124
+ }
125
+ const cfg = resolveCfg(o);
126
+ // An un-enrolled agent has no org to type into. Not a fault, not logged.
127
+ if (!isEnabled(cfg)) return { ok: false, skipped: true };
128
+
129
+ const c = configFromAgent(cfg);
130
+ const params = { channelId, state: o.state === undefined ? true : !!o.state };
131
+ if (o.threadRootId) params.threadRootId = String(o.threadRootId);
132
+
133
+ const frame = await call("messaging.typing", params, {
134
+ base: c.base,
135
+ token: c.token,
136
+ orgId: c.orgId,
137
+ fetchImpl: o.fetchImpl,
138
+ });
139
+
140
+ if (!frame || frame.ok !== true) {
141
+ return { ok: false, error: (frame && frame.error) || { code: "INTERNAL", message: "no frame" } };
142
+ }
143
+ const result = frame.result || {};
144
+ return {
145
+ ok: true,
146
+ scopeKey: result.scopeKey,
147
+ refreshAfterMs: Number.isFinite(result.refreshAfterMs) ? result.refreshAfterMs : undefined,
148
+ };
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // The adapter (typing-registry / BaseAdapter interface)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /** `{chatId, threadRef}` → the heartbeat key. */
156
+ function typingKey(source) {
157
+ const chat = source && source.chatId != null ? String(source.chatId) : "";
158
+ const thread = source && source.threadRef != null ? String(source.threadRef) : "";
159
+ return thread ? `${chat}|${thread}` : chat;
160
+ }
161
+
162
+ function clampInterval(ms) {
163
+ if (!Number.isFinite(ms)) return DEFAULT_INTERVAL_MS;
164
+ return Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, ms));
165
+ }
166
+
167
+ /**
168
+ * Build a Cohort typing adapter with the same two-method surface every channel
169
+ * adapter exposes, so `scripts/daemon/typing-registry.mjs` can drive it with no
170
+ * special-casing.
171
+ *
172
+ * @param {object} opts - {
173
+ * cfg?, agentRoot?, fetchImpl?, intervalMs?, log?,
174
+ * setInterval?, clearInterval?, now?, emit?
175
+ * }
176
+ */
177
+ export function createCohortTypingAdapter(opts = {}) {
178
+ const timers = new Map(); // key -> { handle, source }
179
+ const lastLogAt = new Map(); // key -> ms
180
+ const setIntervalFn = opts.setInterval || globalThis.setInterval;
181
+ const clearIntervalFn = opts.clearInterval || globalThis.clearInterval;
182
+ const nowFn = opts.now || Date.now;
183
+ const emit = opts.emit || emitTyping;
184
+ const log =
185
+ opts.log ||
186
+ ((msg) => {
187
+ try {
188
+ console.warn(`[org/typing] ${msg}`);
189
+ } catch {
190
+ /* never throw from logging */
191
+ }
192
+ });
193
+ let intervalMs = clampInterval(opts.intervalMs || DEFAULT_INTERVAL_MS);
194
+
195
+ /** Fail-open, throttled-loud. Never rejects. */
196
+ function beat(source, state) {
197
+ const key = typingKey(source);
198
+ return Promise.resolve()
199
+ .then(() =>
200
+ emit({
201
+ channelId: source && source.chatId,
202
+ threadRootId: source && source.threadRef,
203
+ state,
204
+ cfg: opts.cfg,
205
+ agentRoot: opts.agentRoot,
206
+ fetchImpl: opts.fetchImpl,
207
+ })
208
+ )
209
+ .then((res) => {
210
+ if (res && res.ok) {
211
+ if (res.refreshAfterMs) intervalMs = clampInterval(res.refreshAfterMs);
212
+ return res;
213
+ }
214
+ // `skipped` = un-enrolled / no channel id: a configuration state, not a
215
+ // failure — reporting it every 3s would be noise, not signal.
216
+ if (!res || !res.skipped) {
217
+ const code = (res && res.error && res.error.code) || "UNKNOWN";
218
+ const message = (res && res.error && res.error.message) || "";
219
+ const at = nowFn();
220
+ const last = lastLogAt.get(key);
221
+ // `undefined` (never logged) must always pass — a `|| 0` default
222
+ // swallows the FIRST report under any clock whose origin is 0.
223
+ if (last === undefined || at - last >= LOG_THROTTLE_MS) {
224
+ lastLogAt.set(key, at);
225
+ log(
226
+ `typing ${state ? "start" : "stop"} failed for ${key || "(no channel)"}: ` +
227
+ `${code} ${message} — the indicator is cosmetic, the session continues`
228
+ );
229
+ }
230
+ }
231
+ return res;
232
+ })
233
+ .catch((err) => {
234
+ // emitTyping itself is fail-open, so this is a programming error; still
235
+ // never propagate it into the session path.
236
+ const at = nowFn();
237
+ const last = lastLogAt.get(key);
238
+ if (last === undefined || at - last >= LOG_THROTTLE_MS) {
239
+ lastLogAt.set(key, at);
240
+ log(`typing beat threw for ${key || "(no channel)"}: ${err && err.message}`);
241
+ }
242
+ return { ok: false, error: { code: "INTERNAL", message: String(err && err.message) } };
243
+ });
244
+ }
245
+
246
+ return {
247
+ /** Service name this adapter registers under. */
248
+ service: "cohort",
249
+
250
+ /**
251
+ * Start beating "composing" for a conversation. Fires once immediately so
252
+ * the indicator appears within one RPC of the session spawning, then
253
+ * re-asserts on the server-advised cadence. Idempotent per key.
254
+ * @returns {string} the heartbeat key ("" when the source names no channel)
255
+ */
256
+ startTypingHeartbeat(source) {
257
+ const key = typingKey(source);
258
+ if (!key) return "";
259
+ if (timers.has(key)) return key; // already beating
260
+ // Register BEFORE the first await so a synchronous double-start can't
261
+ // create two intervals for one conversation.
262
+ const entry = { handle: null, source };
263
+ timers.set(key, entry);
264
+ void beat(source, true);
265
+ const handle = setIntervalFn(() => {
266
+ void beat(source, true);
267
+ }, intervalMs);
268
+ if (handle && typeof handle.unref === "function") handle.unref();
269
+ entry.handle = handle;
270
+ return key;
271
+ },
272
+
273
+ /** Stop the heartbeat and clear the indicator. Idempotent. */
274
+ stopTypingHeartbeat(sourceOrKey) {
275
+ const key = typeof sourceOrKey === "string" ? sourceOrKey : typingKey(sourceOrKey);
276
+ const entry = timers.get(key);
277
+ if (entry) {
278
+ if (entry.handle != null) clearIntervalFn(entry.handle);
279
+ timers.delete(key);
280
+ }
281
+ // Clear explicitly rather than waiting out the server-side TTL: the reply
282
+ // itself lands right after this, and "composing" must not outlive it.
283
+ const source =
284
+ typeof sourceOrKey === "object" && sourceOrKey
285
+ ? sourceOrKey
286
+ : entry && entry.source;
287
+ if (source) void beat(source, false);
288
+ },
289
+
290
+ /** Stop every active heartbeat (shutdown). */
291
+ stopAll() {
292
+ for (const [key, entry] of timers) {
293
+ if (entry.handle != null) clearIntervalFn(entry.handle);
294
+ if (entry.source) void beat(entry.source, false);
295
+ timers.delete(key);
296
+ }
297
+ },
298
+
299
+ /** Active heartbeat keys (introspection / tests). */
300
+ activeKeys() {
301
+ return [...timers.keys()];
302
+ },
303
+
304
+ /** The cadence currently in force (tests / diagnostics). */
305
+ get intervalMs() {
306
+ return intervalMs;
307
+ },
308
+ };
309
+ }
310
+
311
+ let _singleton = null;
312
+
313
+ /**
314
+ * The process-wide Cohort typing adapter (created on first use). The daemon has
315
+ * exactly one agent seat, so one adapter is the right cardinality.
316
+ */
317
+ export function cohortTypingAdapter(opts = {}) {
318
+ if (!_singleton) _singleton = createCohortTypingAdapter(opts);
319
+ return _singleton;
320
+ }
321
+
322
+ /** Drop the singleton (tests). */
323
+ export function resetCohortTypingAdapter() {
324
+ if (_singleton) {
325
+ try {
326
+ _singleton.stopAll();
327
+ } catch {
328
+ /* best-effort */
329
+ }
330
+ }
331
+ _singleton = null;
332
+ }
333
+
334
+ export default {
335
+ emitTyping,
336
+ createCohortTypingAdapter,
337
+ cohortTypingAdapter,
338
+ resetCohortTypingAdapter,
339
+ resetTypingConfigCache,
340
+ DEFAULT_INTERVAL_MS,
341
+ };
@@ -0,0 +1,291 @@
1
+ /**
2
+ * typing.test.mjs — the agent-side "is composing…" indicator.
3
+ *
4
+ * Two units, both hermetic:
5
+ * - emitTyping: params/URL/headers on the wire, the enrolment short-circuit,
6
+ * and fail-open on an error frame (never throws, never claims ok).
7
+ * - the adapter: immediate first beat, re-assert cadence adopted from the
8
+ * server, idempotent start, stop clears the indicator, and — the property
9
+ * that keeps this out of the session path — a permanently failing server
10
+ * produces exactly one log line per throttle window, not one per beat.
11
+ *
12
+ * Uses an injected fetchImpl / emit + injected timers — no network, no clocks.
13
+ * Run: node --test lib/org/typing.test.mjs
14
+ */
15
+ "use strict";
16
+
17
+ import { test } from "node:test";
18
+ import assert from "node:assert/strict";
19
+
20
+ import {
21
+ emitTyping,
22
+ createCohortTypingAdapter,
23
+ cohortTypingAdapter,
24
+ resetCohortTypingAdapter,
25
+ resetTypingConfigCache,
26
+ DEFAULT_INTERVAL_MS,
27
+ MAX_INTERVAL_MS,
28
+ MIN_INTERVAL_MS,
29
+ LOG_THROTTLE_MS,
30
+ } from "./typing.mjs";
31
+
32
+ const BASE = "https://os.cohortapp.com";
33
+ const CFG = { org: { cohort: { enabled: true, base: BASE, token: "tok", orgId: "adaptic" } } };
34
+ const OFF = { org: { cohort: { enabled: false } } };
35
+
36
+ delete process.env.COHORT_API_TOKEN;
37
+ delete process.env.COHORT_TOKEN;
38
+
39
+ /** A fake fetch that records calls and returns a canned res frame. */
40
+ function fakeFetch(responder) {
41
+ const calls = [];
42
+ const impl = async (url, init) => {
43
+ calls.push({
44
+ url,
45
+ method: init && init.method,
46
+ headers: (init && init.headers) || {},
47
+ body: init && init.body ? JSON.parse(init.body) : null,
48
+ });
49
+ const r = responder ? responder(url, init, calls.length - 1) : null;
50
+ const status = r && typeof r.status === "number" ? r.status : 200;
51
+ const ok = r && typeof r.ok === "boolean" ? r.ok : status >= 200 && status < 300;
52
+ const body = r ? r.body : { ok: true, result: { ok: true, refreshAfterMs: 3000 } };
53
+ return { ok, status, headers: { get: () => undefined }, json: async () => body };
54
+ };
55
+ impl.calls = calls;
56
+ return impl;
57
+ }
58
+
59
+ /** Controllable interval timers. */
60
+ function fakeTimers() {
61
+ const timers = new Map();
62
+ let next = 1;
63
+ return {
64
+ timers,
65
+ setInterval: (fn, ms) => {
66
+ const id = next++;
67
+ timers.set(id, { fn, ms });
68
+ return id;
69
+ },
70
+ clearInterval: (id) => timers.delete(id),
71
+ /** Fire every registered interval once. */
72
+ async tick() {
73
+ for (const t of [...timers.values()]) t.fn();
74
+ await new Promise((r) => setImmediate(r));
75
+ },
76
+ };
77
+ }
78
+
79
+ // --- emitTyping -------------------------------------------------------------
80
+
81
+ test("emitTyping: posts messaging.typing with channelId + state, bearer + org pin", async () => {
82
+ const f = fakeFetch();
83
+ const r = await emitTyping({ channelId: "ch_1", cfg: CFG, fetchImpl: f });
84
+
85
+ assert.equal(r.ok, true);
86
+ assert.equal(r.refreshAfterMs, 3000);
87
+ assert.equal(f.calls.length, 1);
88
+ assert.equal(f.calls[0].url, `${BASE}/api/v1/messaging.typing`);
89
+ assert.equal(f.calls[0].method, "POST");
90
+ assert.equal(f.calls[0].headers.authorization, "Bearer tok");
91
+ assert.equal(f.calls[0].headers["x-org-id"], "adaptic");
92
+ assert.deepEqual(f.calls[0].body, { channelId: "ch_1", state: true });
93
+ });
94
+
95
+ test("emitTyping: threadRootId rides through; state:false is a stop", async () => {
96
+ const f = fakeFetch();
97
+ await emitTyping({ channelId: "ch_1", threadRootId: "msg_root", state: false, cfg: CFG, fetchImpl: f });
98
+ assert.deepEqual(f.calls[0].body, { channelId: "ch_1", state: false, threadRootId: "msg_root" });
99
+ });
100
+
101
+ test("emitTyping: no channelId and un-enrolled both skip WITHOUT a request", async () => {
102
+ const f = fakeFetch();
103
+ const noChannel = await emitTyping({ cfg: CFG, fetchImpl: f });
104
+ assert.equal(noChannel.ok, false);
105
+ assert.equal(noChannel.skipped, true);
106
+
107
+ const off = await emitTyping({ channelId: "ch_1", cfg: OFF, fetchImpl: f });
108
+ assert.equal(off.ok, false);
109
+ assert.equal(off.skipped, true);
110
+
111
+ assert.equal(f.calls.length, 0);
112
+ });
113
+
114
+ test("emitTyping: an error frame fails open (no throw) and is NOT reported as skipped", async () => {
115
+ const f = fakeFetch(() => ({ status: 403, body: { ok: false, error: { code: "FORBIDDEN_SCOPE", message: "not a member" } } }));
116
+ const r = await emitTyping({ channelId: "ch_1", cfg: CFG, fetchImpl: f });
117
+ assert.equal(r.ok, false);
118
+ assert.equal(r.skipped, undefined); // a real failure — the adapter must log it
119
+ assert.equal(r.error.code, "FORBIDDEN_SCOPE");
120
+ });
121
+
122
+ test("emitTyping: a dead transport fails open too", async () => {
123
+ const f = async () => {
124
+ throw new Error("ECONNREFUSED");
125
+ };
126
+ const r = await emitTyping({ channelId: "ch_1", cfg: CFG, fetchImpl: f });
127
+ assert.equal(r.ok, false);
128
+ assert.ok(r.error);
129
+ });
130
+
131
+ // --- the adapter ------------------------------------------------------------
132
+
133
+ /** Adapter under test with injected emit + timers. */
134
+ function makeAdapter(emitImpl, over = {}) {
135
+ const calls = [];
136
+ const logs = [];
137
+ const timers = fakeTimers();
138
+ const emit = async (o) => {
139
+ calls.push(o);
140
+ return emitImpl ? emitImpl(o, calls.length - 1) : { ok: true, refreshAfterMs: 3000 };
141
+ };
142
+ const adapter = createCohortTypingAdapter({
143
+ emit,
144
+ log: (m) => logs.push(m),
145
+ setInterval: timers.setInterval,
146
+ clearInterval: timers.clearInterval,
147
+ ...over,
148
+ });
149
+ return { adapter, calls, logs, timers };
150
+ }
151
+
152
+ const SOURCE = { channel: "cohort", chatId: "ch_1", threadRef: undefined, userScope: "channel" };
153
+
154
+ test("startTypingHeartbeat: beats immediately, then on the interval", async () => {
155
+ const { adapter, calls, timers } = makeAdapter();
156
+ const key = adapter.startTypingHeartbeat(SOURCE);
157
+ await new Promise((r) => setImmediate(r));
158
+
159
+ assert.equal(key, "ch_1");
160
+ assert.equal(calls.length, 1, "the indicator must show without waiting a full tick");
161
+ assert.deepEqual({ channelId: calls[0].channelId, state: calls[0].state }, { channelId: "ch_1", state: true });
162
+
163
+ await timers.tick();
164
+ assert.equal(calls.length, 2);
165
+ assert.equal(calls[1].state, true);
166
+ });
167
+
168
+ test("start is idempotent per conversation; thread and channel are distinct keys", async () => {
169
+ const { adapter, timers } = makeAdapter();
170
+ adapter.startTypingHeartbeat(SOURCE);
171
+ adapter.startTypingHeartbeat(SOURCE);
172
+ assert.deepEqual(adapter.activeKeys(), ["ch_1"]);
173
+ assert.equal(timers.timers.size, 1);
174
+
175
+ adapter.startTypingHeartbeat({ chatId: "ch_1", threadRef: "msg_root" });
176
+ assert.deepEqual(adapter.activeKeys(), ["ch_1", "ch_1|msg_root"]);
177
+ });
178
+
179
+ test("stopTypingHeartbeat: clears the timer AND publishes state:false", async () => {
180
+ const { adapter, calls, timers } = makeAdapter();
181
+ adapter.startTypingHeartbeat(SOURCE);
182
+ await new Promise((r) => setImmediate(r));
183
+ adapter.stopTypingHeartbeat(SOURCE);
184
+ await new Promise((r) => setImmediate(r));
185
+
186
+ assert.equal(timers.timers.size, 0);
187
+ assert.deepEqual(adapter.activeKeys(), []);
188
+ assert.equal(calls[calls.length - 1].state, false, "composing must not outlive the reply");
189
+ });
190
+
191
+ test("stopTypingHeartbeat by KEY still clears the indicator (dispatcher close path)", async () => {
192
+ const { adapter, calls } = makeAdapter();
193
+ const key = adapter.startTypingHeartbeat(SOURCE);
194
+ await new Promise((r) => setImmediate(r));
195
+ adapter.stopTypingHeartbeat(key);
196
+ await new Promise((r) => setImmediate(r));
197
+ assert.equal(calls[calls.length - 1].state, false);
198
+ });
199
+
200
+ test("stop is idempotent and a stop for an unknown key is a no-op", async () => {
201
+ const { adapter } = makeAdapter();
202
+ adapter.stopTypingHeartbeat("never-started");
203
+ adapter.stopTypingHeartbeat(SOURCE);
204
+ assert.deepEqual(adapter.activeKeys(), []);
205
+ });
206
+
207
+ test("a source with no channel id never starts a heartbeat", () => {
208
+ const { adapter, timers } = makeAdapter();
209
+ assert.equal(adapter.startTypingHeartbeat({ chatId: "" }), "");
210
+ assert.equal(adapter.startTypingHeartbeat(null), "");
211
+ assert.equal(timers.timers.size, 0);
212
+ });
213
+
214
+ test("the cadence is the SERVER's answer, clamped to sane bounds", async () => {
215
+ const a = makeAdapter(() => ({ ok: true, refreshAfterMs: 4500 }));
216
+ a.adapter.startTypingHeartbeat(SOURCE);
217
+ await new Promise((r) => setImmediate(r));
218
+ assert.equal(a.adapter.intervalMs, 4500);
219
+
220
+ const hot = makeAdapter(() => ({ ok: true, refreshAfterMs: 1 }));
221
+ hot.adapter.startTypingHeartbeat(SOURCE);
222
+ await new Promise((r) => setImmediate(r));
223
+ assert.equal(hot.adapter.intervalMs, MIN_INTERVAL_MS, "a bad value must not become a hot loop");
224
+
225
+ const slow = makeAdapter(() => ({ ok: true, refreshAfterMs: 10 * 60_000 }));
226
+ slow.adapter.startTypingHeartbeat(SOURCE);
227
+ await new Promise((r) => setImmediate(r));
228
+ assert.equal(slow.adapter.intervalMs, MAX_INTERVAL_MS, "a slow value must not blink the indicator");
229
+
230
+ const none = makeAdapter(() => ({ ok: true }));
231
+ none.adapter.startTypingHeartbeat(SOURCE);
232
+ await new Promise((r) => setImmediate(r));
233
+ assert.equal(none.adapter.intervalMs, DEFAULT_INTERVAL_MS);
234
+ });
235
+
236
+ test("a failing server logs ONCE per throttle window, never once per beat", async () => {
237
+ let now = 0;
238
+ const { adapter, logs, timers } = makeAdapter(
239
+ () => ({ ok: false, error: { code: "INTERNAL", message: "boom" } }),
240
+ { now: () => now }
241
+ );
242
+ adapter.startTypingHeartbeat(SOURCE);
243
+ await new Promise((r) => setImmediate(r));
244
+ for (let i = 0; i < 20; i++) {
245
+ now += 1_000;
246
+ await timers.tick();
247
+ }
248
+ assert.equal(logs.length, 1, "20 failed beats over 20s must not be 20 log lines");
249
+ assert.match(logs[0], /typing start failed for ch_1: INTERNAL boom/);
250
+
251
+ now += LOG_THROTTLE_MS;
252
+ await timers.tick();
253
+ assert.equal(logs.length, 2, "but a still-broken indicator must resurface each window");
254
+ });
255
+
256
+ test("a skipped (un-enrolled) beat is never logged — configuration is not failure", async () => {
257
+ const { adapter, logs, timers } = makeAdapter(() => ({ ok: false, skipped: true }));
258
+ adapter.startTypingHeartbeat(SOURCE);
259
+ await new Promise((r) => setImmediate(r));
260
+ await timers.tick();
261
+ assert.deepEqual(logs, []);
262
+ });
263
+
264
+ test("a throwing emit can never reach the session path", async () => {
265
+ const { adapter, logs } = makeAdapter(() => {
266
+ throw new Error("kaboom");
267
+ });
268
+ assert.doesNotThrow(() => adapter.startTypingHeartbeat(SOURCE));
269
+ await new Promise((r) => setImmediate(r));
270
+ assert.equal(logs.length, 1);
271
+ assert.match(logs[0], /threw/);
272
+ });
273
+
274
+ test("stopAll clears every heartbeat (daemon shutdown)", async () => {
275
+ const { adapter, timers } = makeAdapter();
276
+ adapter.startTypingHeartbeat({ chatId: "ch_1" });
277
+ adapter.startTypingHeartbeat({ chatId: "ch_2" });
278
+ adapter.stopAll();
279
+ assert.deepEqual(adapter.activeKeys(), []);
280
+ assert.equal(timers.timers.size, 0);
281
+ });
282
+
283
+ test("cohortTypingAdapter is a process singleton (one seat, one adapter)", () => {
284
+ resetCohortTypingAdapter();
285
+ const a = cohortTypingAdapter();
286
+ assert.equal(cohortTypingAdapter(), a);
287
+ resetCohortTypingAdapter();
288
+ assert.notEqual(cohortTypingAdapter(), a);
289
+ resetCohortTypingAdapter();
290
+ resetTypingConfigCache();
291
+ });