@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.
- package/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +58 -22
- package/lib/org/client.test.mjs +3 -1
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +17 -2
- package/lib/org/messaging.mjs +40 -4
- package/lib/org/messaging.test.mjs +40 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +105 -17
- package/lib/setup/enroll-from-cohort.test.mjs +68 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +8 -3
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +36 -4
- package/scripts/daemon/cadence-handlers.mjs +145 -1
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- 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
|
+
});
|