@cohortapp/agent-sdk 2.3.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -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,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* facts.test.mjs — the agent-side half of the directedness join.
|
|
3
|
+
*
|
|
4
|
+
* Everything here runs against an injected fake IO that RECORDS every call, so
|
|
5
|
+
* the tests can assert not just what was resolved but what was *spent* — and,
|
|
6
|
+
* critically, what was NEVER asked for. The headline assertion is the negative
|
|
7
|
+
* one: a private room outside the agent's roster must never produce a
|
|
8
|
+
* `messaging.history` call. A leak here would be invisible in a "did it deliver"
|
|
9
|
+
* test, so it is asserted by call log.
|
|
10
|
+
*
|
|
11
|
+
* Run: node --test lib/org/inbound/facts.test.mjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
import { test } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
|
|
19
|
+
import { resolveFacts, collectTasks } from "./facts.mjs";
|
|
20
|
+
import { classifyEvent } from "./directedness.mjs";
|
|
21
|
+
|
|
22
|
+
const ME = "M-me";
|
|
23
|
+
const THEM = "M-them";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A fake IO seam. `routes` maps `method` or `read:<path>` to a handler; anything
|
|
27
|
+
* unrouted returns an error frame (which the real seam would have logged).
|
|
28
|
+
*/
|
|
29
|
+
function fakeIo(routes = {}) {
|
|
30
|
+
const calls = [];
|
|
31
|
+
const logs = [];
|
|
32
|
+
return {
|
|
33
|
+
calls,
|
|
34
|
+
logs,
|
|
35
|
+
stats: { calls: 0, reads: 0, failures: 0, methods: [] },
|
|
36
|
+
log: (level, message, meta) => logs.push({ level, message, meta }),
|
|
37
|
+
async call(method, params) {
|
|
38
|
+
calls.push({ kind: "call", method, params });
|
|
39
|
+
this.stats.calls += 1;
|
|
40
|
+
const h = routes[method];
|
|
41
|
+
if (!h) return { ok: false, error: { code: "NOT_FOUND", message: `no route ${method}` } };
|
|
42
|
+
const out = typeof h === "function" ? await h(params) : h;
|
|
43
|
+
return out === undefined ? { ok: true, result: null } : out;
|
|
44
|
+
},
|
|
45
|
+
async read(path) {
|
|
46
|
+
const base = String(path).split("?")[0];
|
|
47
|
+
calls.push({ kind: "read", path, base });
|
|
48
|
+
this.stats.reads += 1;
|
|
49
|
+
const h = routes[`read:${base}`];
|
|
50
|
+
if (!h) return { ok: false, error: { code: "NOT_FOUND", message: `no route ${base}` }, status: 404 };
|
|
51
|
+
const out = typeof h === "function" ? await h(path) : h;
|
|
52
|
+
return out === undefined ? { ok: true, payload: null } : out;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const okFrame = (result) => ({ ok: true, result });
|
|
58
|
+
const okRead = (payload) => ({ ok: true, payload, status: 200 });
|
|
59
|
+
|
|
60
|
+
function ev(over) {
|
|
61
|
+
return { seq: over.seq ?? 1, at: "2026-08-11T00:00:00.000Z", actor: THEM, ...over };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function msgEvent(channelId, channelKind, messageId, extra = {}) {
|
|
65
|
+
return classifyEvent(
|
|
66
|
+
ev({
|
|
67
|
+
family: "messaging",
|
|
68
|
+
kind: "send",
|
|
69
|
+
entity_id: messageId,
|
|
70
|
+
payload: { actor: THEM, channelId, channelKind, mentionCount: 1, threaded: false, ...extra },
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ROSTER = {
|
|
76
|
+
"messaging.channels": okFrame({
|
|
77
|
+
channels: [
|
|
78
|
+
{ id: "C-dm", kind: "DM", slug: "dm-them", name: "them" },
|
|
79
|
+
{ id: "C-pub", kind: "PUBLIC", slug: "general", name: "General" },
|
|
80
|
+
{ id: "C-priv", kind: "PRIVATE", slug: "leads", name: "Leads" },
|
|
81
|
+
],
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// THE negative: never touch a room outside my roster
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
test("NEVER probes a channel outside my roster (asserted by call log)", async () => {
|
|
90
|
+
const io = fakeIo({
|
|
91
|
+
...ROSTER,
|
|
92
|
+
"messaging.history": ({ channelId }) => okFrame({ channelId, messages: [] }),
|
|
93
|
+
});
|
|
94
|
+
const candidates = [
|
|
95
|
+
msgEvent("C-theirs", "DM", "m1"), // someone else's DM — invisible to me
|
|
96
|
+
msgEvent("C-secret", "PRIVATE", "m2"), // a private space I am not in
|
|
97
|
+
];
|
|
98
|
+
await resolveFacts({ candidates, me: ME, io });
|
|
99
|
+
|
|
100
|
+
const histories = io.calls.filter((c) => c.method === "messaging.history");
|
|
101
|
+
assert.equal(histories.length, 0, "no history may be fetched for a room outside my roster");
|
|
102
|
+
assert.equal(io.calls.filter((c) => c.method === "messaging.channels").length, 1);
|
|
103
|
+
assert.ok(
|
|
104
|
+
io.logs.some((l) => /outside my roster/.test(l.message)),
|
|
105
|
+
"the skip must be LOGGED, never silent",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("roster unreadable → nothing is paged at all, and it says so", async () => {
|
|
110
|
+
const io = fakeIo({ "messaging.history": () => okFrame({ messages: [] }) });
|
|
111
|
+
const facts = await resolveFacts({ candidates: [msgEvent("C-dm", "DM", "m1")], me: ME, io });
|
|
112
|
+
|
|
113
|
+
assert.equal(facts.visibleChannelIds, null);
|
|
114
|
+
assert.equal(facts.memberChannelIds, null);
|
|
115
|
+
assert.deepEqual(facts.degraded, ["channels"]);
|
|
116
|
+
assert.equal(io.calls.filter((c) => c.method === "messaging.history").length, 0);
|
|
117
|
+
assert.ok(io.logs.some((l) => /roster/.test(l.message)));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// roster → visible vs member
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
test("a PUBLIC channel is visible but NOT proof of membership", async () => {
|
|
125
|
+
const io = fakeIo({ ...ROSTER, "messaging.history": () => okFrame({ messages: [] }) });
|
|
126
|
+
const facts = await resolveFacts({ candidates: [msgEvent("C-pub", "PUBLIC", "m1")], me: ME, io });
|
|
127
|
+
|
|
128
|
+
assert.deepEqual([...facts.visibleChannelIds].sort(), ["C-dm", "C-priv", "C-pub"]);
|
|
129
|
+
assert.deepEqual([...facts.memberChannelIds].sort(), ["C-dm", "C-priv"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// cost: the redacted counts avoid pages entirely
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
test("a space message with mentionCount=0 and threaded=false costs ZERO history calls", async () => {
|
|
137
|
+
const io = fakeIo({ ...ROSTER, "messaging.history": () => okFrame({ messages: [] }) });
|
|
138
|
+
const cand = classifyEvent(
|
|
139
|
+
ev({
|
|
140
|
+
family: "messaging",
|
|
141
|
+
kind: "send",
|
|
142
|
+
entity_id: "m1",
|
|
143
|
+
payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 0, threaded: false },
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
await resolveFacts({ candidates: [cand], me: ME, io });
|
|
147
|
+
assert.equal(io.calls.filter((c) => c.method === "messaging.history").length, 0);
|
|
148
|
+
assert.ok(io.logs.some((l) => /mentionCount=0/.test(l.message)));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("one history page per affected channel, not per message", async () => {
|
|
152
|
+
let pages = 0;
|
|
153
|
+
const io = fakeIo({
|
|
154
|
+
...ROSTER,
|
|
155
|
+
"messaging.history": ({ channelId }) => {
|
|
156
|
+
pages += 1;
|
|
157
|
+
return okFrame({ channelId, messages: [{ id: "m1", authorId: THEM, body: "a", mentions: [ME] }] });
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
await resolveFacts({
|
|
161
|
+
candidates: [msgEvent("C-pub", "PUBLIC", "m1"), msgEvent("C-pub", "PUBLIC", "m2"), msgEvent("C-pub", "PUBLIC", "m3")],
|
|
162
|
+
me: ME,
|
|
163
|
+
io,
|
|
164
|
+
});
|
|
165
|
+
assert.equal(pages, 1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("the channel-page cap bites and is logged", async () => {
|
|
169
|
+
const channels = Array.from({ length: 5 }, (_, i) => ({ id: `C-${i}`, kind: "PUBLIC", slug: `c${i}` }));
|
|
170
|
+
const io = fakeIo({
|
|
171
|
+
"messaging.channels": okFrame({ channels }),
|
|
172
|
+
"messaging.history": ({ channelId }) => okFrame({ channelId, messages: [] }),
|
|
173
|
+
});
|
|
174
|
+
await resolveFacts({
|
|
175
|
+
candidates: channels.map((c, i) => msgEvent(c.id, "PUBLIC", `m${i}`)),
|
|
176
|
+
me: ME,
|
|
177
|
+
io,
|
|
178
|
+
limits: { maxChannelPages: 2 },
|
|
179
|
+
});
|
|
180
|
+
assert.equal(io.calls.filter((c) => c.method === "messaging.history").length, 2);
|
|
181
|
+
assert.ok(io.logs.some((l) => /capping at 2/.test(l.message)));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// thread roots
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
test("myThreadRootIds records threads I authored in and threads that named me", async () => {
|
|
189
|
+
const io = fakeIo({
|
|
190
|
+
...ROSTER,
|
|
191
|
+
"messaging.history": () =>
|
|
192
|
+
okFrame({
|
|
193
|
+
messages: [
|
|
194
|
+
{ id: "r1", authorId: ME, body: "kick off", mentions: [] },
|
|
195
|
+
{ id: "m2", authorId: THEM, body: "reply", threadRootId: "r1", mentions: [] },
|
|
196
|
+
{ id: "m3", authorId: THEM, body: "hi", threadRootId: "r2", mentions: [ME] },
|
|
197
|
+
{ id: "m4", authorId: THEM, body: "nothing to do with me", threadRootId: "r3", mentions: [] },
|
|
198
|
+
],
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
const facts = await resolveFacts({ candidates: [msgEvent("C-pub", "PUBLIC", "m2")], me: ME, io });
|
|
202
|
+
assert.deepEqual([...facts.myThreadRootIds].sort(), ["r1", "r2"]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// board
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
test("board.context resolves assignee/reviewer roles out of the swimlane projection", async () => {
|
|
210
|
+
const io = fakeIo({
|
|
211
|
+
"read:board.context": okRead({
|
|
212
|
+
lanes: [
|
|
213
|
+
{
|
|
214
|
+
workstreamId: "W-1",
|
|
215
|
+
columns: {
|
|
216
|
+
doing: [{ id: "T-1", title: "mine", col: "doing", assigneeId: ME, reviewerId: null }],
|
|
217
|
+
review: [{ id: "T-2", title: "review", col: "review", assigneeId: THEM, reviewerId: ME }],
|
|
218
|
+
done: [{ id: "T-3", title: "theirs", col: "done", assigneeId: THEM, reviewerId: THEM }],
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
}),
|
|
223
|
+
"board.taskComments": okFrame({ comments: [] }),
|
|
224
|
+
});
|
|
225
|
+
const cand = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }));
|
|
226
|
+
const facts = await resolveFacts({ candidates: [cand], me: ME, io });
|
|
227
|
+
|
|
228
|
+
assert.equal(facts.taskRole.get("T-1"), "assignee");
|
|
229
|
+
assert.equal(facts.taskRole.get("T-2"), "reviewer");
|
|
230
|
+
assert.equal(facts.taskRole.has("T-3"), false);
|
|
231
|
+
assert.equal(facts.tasks.get("T-1").title, "mine");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("board.context unreadable → taskRole empty-but-present + degraded + logged", async () => {
|
|
235
|
+
const io = fakeIo({});
|
|
236
|
+
const cand = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }));
|
|
237
|
+
const facts = await resolveFacts({ candidates: [cand], me: ME, io });
|
|
238
|
+
assert.equal(facts.taskRole, null);
|
|
239
|
+
assert.deepEqual(facts.degraded, ["board"]);
|
|
240
|
+
assert.ok(facts.degraded.length && io.logs.some((l) => /board\.context/.test(l.message)));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("collectTasks is defensive about shape changes", () => {
|
|
244
|
+
assert.deepEqual(collectTasks(null), []);
|
|
245
|
+
assert.deepEqual(collectTasks({ nope: 1 }), []);
|
|
246
|
+
assert.equal(collectTasks([{ id: "T-1", col: "doing" }]).length, 1);
|
|
247
|
+
// No infinite recursion on a deep/odd payload.
|
|
248
|
+
assert.doesNotThrow(() => collectTasks({ a: { b: { c: { d: { e: { f: { g: 1 } } } } } } }));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// decision / escalation / approval / files probes
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
test("decision facts: list read + one listComments probe, skipped when I proposed it", async () => {
|
|
256
|
+
const io = fakeIo({
|
|
257
|
+
"read:decision.list": okRead({
|
|
258
|
+
decisions: [
|
|
259
|
+
{ id: "D-mine", title: "mine", proposedBy: ME },
|
|
260
|
+
{ id: "D-theirs", title: "theirs", proposedBy: THEM },
|
|
261
|
+
],
|
|
262
|
+
}),
|
|
263
|
+
"decision.listComments": ({ decisionId }) =>
|
|
264
|
+
okFrame({ decisionId, comments: [{ id: "c1", authorId: ME, body: "+1" }] }),
|
|
265
|
+
});
|
|
266
|
+
const cands = [
|
|
267
|
+
classifyEvent(ev({ family: "decision", kind: "decision.comment", entity_id: "D-mine", payload: { authorId: THEM } })),
|
|
268
|
+
classifyEvent(ev({ family: "decision", kind: "decision.comment", entity_id: "D-theirs", payload: { authorId: THEM } })),
|
|
269
|
+
];
|
|
270
|
+
const facts = await resolveFacts({ candidates: cands, me: ME, io });
|
|
271
|
+
|
|
272
|
+
const probes = io.calls.filter((c) => c.method === "decision.listComments");
|
|
273
|
+
assert.deepEqual(probes.map((p) => p.params.decisionId), ["D-theirs"], "no probe needed for one I proposed");
|
|
274
|
+
assert.ok(facts.decisionVoices.get("D-theirs").has(ME));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("escalation facts come from one list read", async () => {
|
|
278
|
+
const io = fakeIo({
|
|
279
|
+
"messaging.channels": okFrame({ channels: [] }),
|
|
280
|
+
"read:board.context": okRead({ lanes: [] }),
|
|
281
|
+
"escalation.list": okFrame({ escalations: [{ id: "E-1", title: "prod", waitingOn: ME }] }),
|
|
282
|
+
});
|
|
283
|
+
const cand = classifyEvent(ev({ family: "escalation", kind: "escalation.raised", entity_id: "E-1", payload: { actor: THEM } }));
|
|
284
|
+
const facts = await resolveFacts({ candidates: [cand], me: ME, io });
|
|
285
|
+
assert.equal(facts.escalations.get("E-1").waitingOn, ME);
|
|
286
|
+
assert.equal(io.calls.filter((c) => c.method === "escalation.list").length, 1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("approval facts: the approver seat is READ BACK, because the payload never names it", async () => {
|
|
290
|
+
const io = fakeIo({
|
|
291
|
+
"approval.get": ({ id }) => okFrame({ id, requester: THEM, approver: ME, status: "pending", actionClass: "spend" }),
|
|
292
|
+
});
|
|
293
|
+
const cand = classifyEvent(
|
|
294
|
+
ev({ family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { requester: THEM, actionClass: "spend" } }),
|
|
295
|
+
);
|
|
296
|
+
const facts = await resolveFacts({ candidates: [cand], me: ME, io });
|
|
297
|
+
assert.equal(facts.approvals.get("A-1").approver, ME);
|
|
298
|
+
assert.deepEqual(io.calls.filter((c) => c.method === "approval.get").map((c) => c.params.id), ["A-1"]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("approval probes are de-duplicated and capped", async () => {
|
|
302
|
+
const io = fakeIo({ "approval.get": ({ id }) => okFrame({ id, requester: THEM, approver: ME }) });
|
|
303
|
+
const cands = [
|
|
304
|
+
classifyEvent(ev({ seq: 1, family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { requester: THEM } })),
|
|
305
|
+
classifyEvent(ev({ seq: 2, family: "approval", kind: "approval.approved", entity_id: "A-1", payload: { requester: THEM } })),
|
|
306
|
+
classifyEvent(ev({ seq: 3, family: "approval", kind: "approval.requested", entity_id: "A-2", payload: { requester: THEM } })),
|
|
307
|
+
];
|
|
308
|
+
await resolveFacts({ candidates: cands, me: ME, io, limits: { maxApprovalProbes: 1 } });
|
|
309
|
+
assert.equal(io.calls.filter((c) => c.method === "approval.get").length, 1);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("doc facts: files.get IS the ACL — an invisible doc simply never appears", async () => {
|
|
313
|
+
const io = fakeIo({
|
|
314
|
+
"files.get": ({ fileId }) => {
|
|
315
|
+
if (fileId === "F-mine") return okFrame({ file: { id: "F-mine", name: "spec", ownerId: ME }, permission: "owner" });
|
|
316
|
+
if (fileId === "F-shared") return okFrame({ file: { id: "F-shared", name: "plan", ownerId: THEM }, permission: "comment" });
|
|
317
|
+
return { ok: false, error: { code: "NOT_FOUND", message: "no such file" } };
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
const mk = (id) => classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: id, payload: { name: "x" } }));
|
|
321
|
+
const facts = await resolveFacts({ candidates: [mk("F-mine"), mk("F-shared"), mk("F-hidden")], me: ME, io });
|
|
322
|
+
|
|
323
|
+
assert.equal(facts.fileAcl.get("F-mine").ownerId, ME);
|
|
324
|
+
assert.equal(facts.fileAcl.get("F-shared").shared, true);
|
|
325
|
+
assert.equal(facts.fileAcl.has("F-hidden"), false, "a doc I cannot read must not get an ACL entry");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("doc facts: a prose-name probe runs only when myNames is configured", async () => {
|
|
329
|
+
const routes = {
|
|
330
|
+
"files.get": ({ fileId }) => okFrame({ file: { id: fileId, name: "spec", ownerId: THEM }, permission: "none" }),
|
|
331
|
+
"files.comments": () => okFrame({ comments: [{ id: "c1", authorId: THEM, body: "Isla should look at this" }] }),
|
|
332
|
+
};
|
|
333
|
+
const mk = () => classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-1", payload: {} }));
|
|
334
|
+
|
|
335
|
+
const without = fakeIo(routes);
|
|
336
|
+
await resolveFacts({ candidates: [mk()], me: ME, io: without });
|
|
337
|
+
assert.equal(without.calls.filter((c) => c.method === "files.comments").length, 0);
|
|
338
|
+
|
|
339
|
+
const with_ = fakeIo(routes);
|
|
340
|
+
const facts = await resolveFacts({ candidates: [mk()], me: ME, io: with_, myNames: ["Isla"] });
|
|
341
|
+
assert.equal(with_.calls.filter((c) => c.method === "files.comments").length, 1);
|
|
342
|
+
assert.equal(facts.fileAcl.get("F-1").mentionsMe, true);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// no work → no spend
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
test("a family absent from the batch costs nothing", async () => {
|
|
350
|
+
const io = fakeIo({ ...ROSTER, "messaging.history": () => okFrame({ messages: [] }) });
|
|
351
|
+
await resolveFacts({ candidates: [msgEvent("C-dm", "DM", "m1")], me: ME, io });
|
|
352
|
+
const methods = io.calls.map((c) => c.method || c.base);
|
|
353
|
+
assert.equal(methods.includes("board.context"), false);
|
|
354
|
+
assert.equal(methods.includes("escalation.list"), false);
|
|
355
|
+
assert.equal(methods.includes("approval.get"), false);
|
|
356
|
+
assert.equal(methods.includes("files.get"), false);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("an empty batch, or no io, resolves to an inert fact set", async () => {
|
|
360
|
+
const a = await resolveFacts({ candidates: [], me: ME, io: fakeIo({}) });
|
|
361
|
+
assert.equal(a.channelPages.size, 0);
|
|
362
|
+
const b = await resolveFacts({ candidates: [msgEvent("C-dm", "DM", "m1")], me: ME, io: null });
|
|
363
|
+
assert.equal(b.visibleChannelIds, null);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("a disabled surface is never probed for", async () => {
|
|
367
|
+
const io = fakeIo({ ...ROSTER, "messaging.history": () => okFrame({ messages: [] }) });
|
|
368
|
+
await resolveFacts({
|
|
369
|
+
candidates: [msgEvent("C-pub", "PUBLIC", "m1")],
|
|
370
|
+
me: ME,
|
|
371
|
+
io,
|
|
372
|
+
enabled: { dm: true, mention: false, thread_reply: false },
|
|
373
|
+
});
|
|
374
|
+
assert.equal(io.calls.filter((c) => c.method === "messaging.history").length, 0);
|
|
375
|
+
});
|