@cohortapp/agent-sdk 2.11.0 → 2.11.2
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/lib/channels/inbox-item.mjs +12 -0
- package/lib/channels/inbox-item.test.mjs +33 -0
- package/lib/execution/disposition.mjs +13 -2
- package/lib/execution/disposition.test.mjs +19 -2
- package/lib/execution/pipeline.test.mjs +4 -1
- package/lib/org/messaging.mjs +5 -0
- package/lib/org/messaging.test.mjs +7 -0
- package/package.json +1 -1
- package/scripts/daemon/agent-daemon.mjs +62 -2
- package/scripts/daemon/agent-daemon.test.mjs +117 -0
- package/scripts/daemon/assurance-e2e.test.mjs +4 -3
- package/scripts/daemon/assurance.mjs +34 -74
- package/scripts/daemon/assurance.test.mjs +26 -26
- package/scripts/daemon/cadence-handlers.mjs +48 -5
- package/scripts/daemon/cadence-handlers.test.mjs +57 -2
- package/scripts/daemon/classifier.mjs +27 -14
- package/scripts/daemon/execution-ladder.test.mjs +23 -0
- package/scripts/daemon/inbox-deferral.mjs +49 -24
- package/scripts/daemon/inbox-deferral.test.mjs +39 -1
- package/scripts/daemon/responder.mjs +20 -0
- package/scripts/poller/inbox-scan-poller.mjs +7 -0
- package/scripts/poller/utils.mjs +11 -0
|
@@ -182,6 +182,18 @@ export function eventToInboxItem(ev) {
|
|
|
182
182
|
if (typeof ev.is_private === "boolean") item.is_private = ev.is_private;
|
|
183
183
|
if (typeof ev.is_dm === "boolean") item.is_dm = ev.is_dm;
|
|
184
184
|
|
|
185
|
+
// THE CHANNEL KIND (DM / GROUP_DM / PUBLIC / PRIVATE / …). Cohort carries this
|
|
186
|
+
// on its org event feed (lib/org/messaging.toMessageEvent), and it is the ONLY
|
|
187
|
+
// reliable is_dm signal for Cohort, whose channel ids are cuids with no
|
|
188
|
+
// Slack-style "D…"/"dm/" prefix for the daemon's `enrichItem` to key off. The
|
|
189
|
+
// signal was being DROPPED here (and again by the YAML writer/reader), so a
|
|
190
|
+
// Cohort 1:1 DM arrived at the election gate looking like an ambient group
|
|
191
|
+
// message and was suppressed. Serialised only when present, so every non-Cohort
|
|
192
|
+
// producer keeps a byte-identical inbox item (see the byte-compat fixture).
|
|
193
|
+
if (typeof ev.channel_kind === "string" && ev.channel_kind) {
|
|
194
|
+
item.channel_kind = sanitizeYamlField(ev.channel_kind);
|
|
195
|
+
}
|
|
196
|
+
|
|
185
197
|
// Only stamp `kind` when it's not the default. Existing message items must
|
|
186
198
|
// remain byte-identical to what the Slack socket-mode writer produced before
|
|
187
199
|
// this seam existed (it never wrote a `kind` field).
|
|
@@ -59,6 +59,39 @@ test("eventToInboxItem: channel_cc kind is stamped", () => {
|
|
|
59
59
|
assert.equal(item.kind, "channel_cc");
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
test("eventToInboxItem: channel_kind is carried through when present, omitted when absent", () => {
|
|
63
|
+
// Present → serialised (Cohort's only reliable is_dm signal), and the poll-lane
|
|
64
|
+
// parser reads it back, so enrichItem actually receives it.
|
|
65
|
+
const withKind = eventToInboxItem({
|
|
66
|
+
channel: "cohort",
|
|
67
|
+
message_id: "m1",
|
|
68
|
+
channel_kind: "DM",
|
|
69
|
+
from: { id: "human-1", name: "Human" },
|
|
70
|
+
text: "hi",
|
|
71
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
72
|
+
});
|
|
73
|
+
assert.equal(withKind.channel_kind, "DM");
|
|
74
|
+
|
|
75
|
+
// Absent → omitted entirely, so a non-Cohort item stays byte-identical.
|
|
76
|
+
const withoutKind = eventToInboxItem({
|
|
77
|
+
channel: "slack",
|
|
78
|
+
message_id: "m2",
|
|
79
|
+
from: { id: "U1", name: "casey" },
|
|
80
|
+
text: "hi",
|
|
81
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
82
|
+
});
|
|
83
|
+
assert.equal("channel_kind" in withoutKind, false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("parseInboxItemYaml: channel_kind survives the YAML round-trip (present/absent)", () => {
|
|
87
|
+
const withKind = parseInboxItemYaml(
|
|
88
|
+
'id: "m1"\nservice: "cohort"\nchannel_id: "cuid_abc"\nchannel_kind: "GROUP_DM"\ncontent: |\n hi\n',
|
|
89
|
+
);
|
|
90
|
+
assert.equal(withKind.channel_kind, "GROUP_DM");
|
|
91
|
+
const withoutKind = parseInboxItemYaml('id: "m2"\nservice: "slack"\ncontent: |\n hi\n');
|
|
92
|
+
assert.equal("channel_kind" in withoutKind, false, "absent line → no channel_kind key");
|
|
93
|
+
});
|
|
94
|
+
|
|
62
95
|
// ---------------------------------------------------------------------------
|
|
63
96
|
// round-trip event ↔ item
|
|
64
97
|
// ---------------------------------------------------------------------------
|
|
@@ -392,8 +392,19 @@ export function decide(o = {}) {
|
|
|
392
392
|
if (o.history && base.actor) {
|
|
393
393
|
const hits = o.history.actorActivity(base.actor);
|
|
394
394
|
if (hits >= floodLimit) {
|
|
395
|
-
|
|
396
|
-
|
|
395
|
+
// A DIRECTED 1:1 DM is EXEMPT. The flood counter tallies ALL of an actor's
|
|
396
|
+
// activity in the window — including their own doc/board/decision events,
|
|
397
|
+
// which fire far more often than they message — so a person's genuine 1:1
|
|
398
|
+
// message would be downgraded to "batch the reply" merely because their
|
|
399
|
+
// unrelated document edits tripped the limit. A 1:1 DM is the one surface
|
|
400
|
+
// where the human is unambiguously talking TO the seat and watching for a
|
|
401
|
+
// reply, so it stays react-now; every other surface still batches.
|
|
402
|
+
if (base.surface === "dm") {
|
|
403
|
+
why.push(`${base.actor} is over the flood limit (${hits}/${floodLimit}), but this is a directed 1:1 DM → not batched`);
|
|
404
|
+
} else {
|
|
405
|
+
why.push(`${base.actor} has woken me ${hits} time(s) in the flood window (limit ${floodLimit}) → batch the reply`);
|
|
406
|
+
return finish(base, "schedule", "actor_flood", why, o, { observeOnly, batched: true });
|
|
407
|
+
}
|
|
397
408
|
}
|
|
398
409
|
}
|
|
399
410
|
|
|
@@ -256,15 +256,32 @@ test("IGNORE: a malformed event never crashes the ladder", () => {
|
|
|
256
256
|
// ───────────────────────────────────────────────────────────────────────────
|
|
257
257
|
|
|
258
258
|
test("DOWNGRADE not silence: a flooding actor gets batched, not muted", () => {
|
|
259
|
-
|
|
259
|
+
// Assert on a NON-DM surface (an @mention): a directed 1:1 DM is now EXEMPT
|
|
260
|
+
// from actor-flood (see the next test), so it can no longer stand in for the
|
|
261
|
+
// general batching behaviour.
|
|
262
|
+
const floodMention = (over) => run({
|
|
263
|
+
verdict: yes("mention", "mention"),
|
|
264
|
+
candidate: { family: "messaging", kind: "send", surfaces: ["mention"] },
|
|
265
|
+
...over,
|
|
266
|
+
});
|
|
267
|
+
const d = floodMention({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT }) });
|
|
260
268
|
assert.equal(d.disposition, "schedule");
|
|
261
269
|
assert.equal(d.reason, "actor_flood");
|
|
262
270
|
assert.equal(d.batched, true);
|
|
263
271
|
// one under the limit still answers turn-by-turn
|
|
264
|
-
const under =
|
|
272
|
+
const under = floodMention({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT - 1 }) });
|
|
265
273
|
assert.equal(under.disposition, "react_now");
|
|
266
274
|
});
|
|
267
275
|
|
|
276
|
+
test("a directed 1:1 DM is EXEMPT from actor-flood batching", () => {
|
|
277
|
+
// The flood counter tallies ALL of an actor's activity (their doc/board edits
|
|
278
|
+
// included), so a genuine 1:1 message must not be batched just because the same
|
|
279
|
+
// person tripped the limit elsewhere. Even far over the limit, a DM reacts now.
|
|
280
|
+
const d = run({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT * 5 }) });
|
|
281
|
+
assert.equal(d.disposition, "react_now");
|
|
282
|
+
assert.equal(d.reason, "directed_now");
|
|
283
|
+
});
|
|
284
|
+
|
|
268
285
|
test("DOWNGRADE not silence: an exhausted budget queues the reply", () => {
|
|
269
286
|
for (const runtime of [{ dailyCapExhausted: true }, { remainingCents: 0 }]) {
|
|
270
287
|
const d = run({ runtime });
|
|
@@ -157,7 +157,10 @@ test("a burst from one actor is batched rather than answered turn-by-turn", asyn
|
|
|
157
157
|
const fx = fakeEffects();
|
|
158
158
|
const items = [];
|
|
159
159
|
for (let i = 0; i < 6; i += 1) {
|
|
160
|
-
|
|
160
|
+
// A burst of @mentions (NOT DMs): a directed 1:1 DM is now exempt from
|
|
161
|
+
// actor-flood, so flood batching is exercised on a channel surface where
|
|
162
|
+
// it still applies. `is_dm:false` + mentions_agent → surface "mention".
|
|
163
|
+
items.push(dm({ id: `cohort-msg_${i}`, raw_ref: `cohort:mention:msg_${i}:${100 + i}`, thread_id: `t_${i}`, channel_id: `c_${i}`, is_dm: false }));
|
|
161
164
|
}
|
|
162
165
|
const { results } = await run(items, { agentRoot: root, effects: fx, actorFloodLimit: 3 });
|
|
163
166
|
const dispositions = results.map((r) => r.decision.disposition);
|
package/lib/org/messaging.mjs
CHANGED
|
@@ -859,6 +859,11 @@ export function toMessageEvent(ev, me) {
|
|
|
859
859
|
kind: isCall ? "call" : "message",
|
|
860
860
|
subject: isCall ? "Cohort call invite" : "Cohort message",
|
|
861
861
|
channel_id: channelId,
|
|
862
|
+
// The org event feed carries channelKind (DM/GROUP_DM/PUBLIC/…); surface it
|
|
863
|
+
// so the daemon's is_dm detection works for Cohort (whose channel ids are
|
|
864
|
+
// cuids, not Slack-style "D…"/"dm/…" prefixes). Without this a Cohort DM was
|
|
865
|
+
// misread as a group message and wrongly routed through the election gate.
|
|
866
|
+
channel_kind: String(p.channelKind || p.channel_kind || "").toUpperCase(),
|
|
862
867
|
channel_label: String(p.channelName || channelId || "cohort"),
|
|
863
868
|
sender: String(p.fromName || fromId),
|
|
864
869
|
priority_signals: {
|
|
@@ -248,6 +248,13 @@ test("toMessageEvent: projects a messaging event onto the MessageEvent contract"
|
|
|
248
248
|
assert.equal(ev.ingest_source, "cohort");
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
+
test("toMessageEvent: projects channelKind so the daemon can tell a DM from a GROUP_DM", () => {
|
|
252
|
+
const dm = toMessageEvent({ family: "messaging", seq: 8, payload: { id: "m1", channel: "dm-x", channelKind: "DM", from: "human-1", body: "hi" } }, "me");
|
|
253
|
+
assert.equal(dm.channel_kind, "DM", "a 1:1 DM carries channel_kind DM (→ is_dm true downstream)");
|
|
254
|
+
const group = toMessageEvent({ family: "messaging", seq: 9, payload: { id: "m2", channel: "gdm-x", channelKind: "group_dm", from: "human-1", body: "hi all" } }, "me");
|
|
255
|
+
assert.equal(group.channel_kind, "GROUP_DM", "a GROUP_DM is upper-cased and NOT a 1:1 DM downstream");
|
|
256
|
+
});
|
|
257
|
+
|
|
251
258
|
test("pullInbound: filters to directed events and advances the cursor to max seq", async () => {
|
|
252
259
|
const f = fakeFetch(() => ({
|
|
253
260
|
body: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.2",
|
|
4
4
|
"description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
sweepObligations,
|
|
70
70
|
openObligations,
|
|
71
71
|
escalate,
|
|
72
|
+
closeObligation,
|
|
72
73
|
} from "./assurance.mjs";
|
|
73
74
|
import { recordPoll, recordClassification, recordSession, writeHealthDashboard } from "./health.mjs";
|
|
74
75
|
import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
|
|
@@ -326,7 +327,29 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
|
|
|
326
327
|
export function enrichItem(item, service) {
|
|
327
328
|
const channelStr = (item.channel || "").toLowerCase();
|
|
328
329
|
const channelId = item.channel_id || "";
|
|
329
|
-
|
|
330
|
+
// Cohort carries the channel kind explicitly (DM/GROUP_DM/…); its channel ids
|
|
331
|
+
// are cuids, so the Slack-style "dm/"/"D…" prefix heuristics never fire. When
|
|
332
|
+
// the kind is known it is AUTHORITATIVE:
|
|
333
|
+
// - DM → a 1:1 DM: is_dm true, so it skips the group election gate.
|
|
334
|
+
// - GROUP_DM → a multi-participant channel: is_dm FALSE, so an UNDIRECTED
|
|
335
|
+
// message goes through the election exactly like any other
|
|
336
|
+
// channel (only ONE seat should answer an ambient group DM).
|
|
337
|
+
// - any other explicit kind (PUBLIC/PRIVATE/…) → not a 1:1 DM.
|
|
338
|
+
// When the kind is ABSENT we do NOT immediately reach for the prefix
|
|
339
|
+
// heuristics: the DEFAULT (wide) inbound reader — `lib/org/inbound/project.mjs`
|
|
340
|
+
// toMessageEvent — projects a Cohort 1:1 DM with `is_dm:true`/`channel_type:'dm'`
|
|
341
|
+
// but does NOT emit `channel_kind`. That boolean survives the whole
|
|
342
|
+
// eventToInboxItem → writeInboxItem → parseInboxItemYaml chain, so HONOR it
|
|
343
|
+
// before falling back. Overwriting it with the cuid-blind prefix heuristics
|
|
344
|
+
// (which never match a Cohort id) is exactly what sent a genuine 1:1 DM through
|
|
345
|
+
// the group election gate and suppressed it. Precedence: explicit kind →
|
|
346
|
+
// pre-existing boolean is_dm → Slack-style id/label prefix heuristics.
|
|
347
|
+
const channelKind = String(item.channel_kind || "").toUpperCase();
|
|
348
|
+
const isDm = channelKind
|
|
349
|
+
? channelKind === "DM"
|
|
350
|
+
: typeof item.is_dm === "boolean"
|
|
351
|
+
? item.is_dm
|
|
352
|
+
: channelStr.startsWith("dm/") || channelId.startsWith("D");
|
|
330
353
|
const myFirstName = loadAgent().firstName || "Agent";
|
|
331
354
|
const agentThreadRegex = new RegExp(`^${myFirstName}:`, "m");
|
|
332
355
|
const agentInThread = !!(item.thread_context && agentThreadRegex.test(item.thread_context));
|
|
@@ -697,7 +720,44 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
697
720
|
});
|
|
698
721
|
return { ok: true, path: "quick_reply", reason: classResult.model };
|
|
699
722
|
}
|
|
700
|
-
//
|
|
723
|
+
// PERMANENT SEND FAILURE — NEVER FALL THROUGH. A permanent failure
|
|
724
|
+
// (FORBIDDEN_SCOPE from the send-gate, NOT_FOUND/BAD_REQUEST from hq) means
|
|
725
|
+
// retrying changes nothing AND that a spawned session would fail to post the
|
|
726
|
+
// very same way. Worse, that session runs AUTONOMOUSLY: denied its intended
|
|
727
|
+
// reply, it improvises and posts UNRELATED work into a channel this seat was
|
|
728
|
+
// just told it may not write to. So we stop here — open the durable debt so
|
|
729
|
+
// the ask is not lost, write a needs-attention escalation an operator can
|
|
730
|
+
// sweep, close the obligation as failed so the assurance sweep does not loop
|
|
731
|
+
// on an impossible send, and retire the item so the poll lane stops
|
|
732
|
+
// re-delivering it. The requester is not told (the only channel we had was
|
|
733
|
+
// the forbidden one); the escalation is the trace that a human must act.
|
|
734
|
+
if (result.permanent) {
|
|
735
|
+
const cause = result.code || result.error || "permanent send failure";
|
|
736
|
+
console.error(`[daemon] Quick reply PERMANENTLY failed for ${item.sender} (${cause}) — NOT spawning a session; opening obligation + escalating`);
|
|
737
|
+
let obligationKey = itemId;
|
|
738
|
+
try {
|
|
739
|
+
const opened = await openAndAcknowledge({
|
|
740
|
+
item, classResult, service, traceId: trace_id, ack: false,
|
|
741
|
+
});
|
|
742
|
+
if (opened && opened.key) obligationKey = opened.key;
|
|
743
|
+
} catch (err) {
|
|
744
|
+
console.error(`[daemon] openAndAcknowledge threw for ${itemId} on permanent send failure: ${err.message}`);
|
|
745
|
+
}
|
|
746
|
+
escalate(
|
|
747
|
+
{ key: obligationKey, sender: item.sender, service,
|
|
748
|
+
channel: item.channel_id || item.channel || null,
|
|
749
|
+
summary: classResult && classResult.summary, openedAt: Date.now(),
|
|
750
|
+
attempts: 1, sessionId: null, traceId: trace_id, lastError: cause,
|
|
751
|
+
item: { content: item.content } },
|
|
752
|
+
{ failure: { label: cause }, told: false },
|
|
753
|
+
);
|
|
754
|
+
try { closeObligation(obligationKey, { outcome: "failed", note: cause }); } catch { /* best-effort */ }
|
|
755
|
+
counters.bump("send.permanent_failure", { service, code: result.code || "unknown" });
|
|
756
|
+
emitEvent({ type: EVENT_TYPES.ERROR, trace_id, attrs: { item_id: itemId, service, stage: "quick_reply_permanent", error: cause } });
|
|
757
|
+
markProcessed(item, service);
|
|
758
|
+
return { ok: true, path: "quick_reply_permanent", reason: cause };
|
|
759
|
+
}
|
|
760
|
+
// If quick reply failed transiently or was blocked by validation, fall through to dispatch a full session
|
|
701
761
|
const reason = result.blocked ? `validation blocked: ${result.issues?.map(i => i.rule).join(", ")}` : "send failed";
|
|
702
762
|
console.warn(`[daemon] Quick reply not sent (${reason}), falling through to session dispatch`);
|
|
703
763
|
}
|
|
@@ -713,3 +713,120 @@ test("FAIL-SAFE: no holding ack is posted when the reply session cannot run (cla
|
|
|
713
713
|
assert.equal(down.dispatched, true, "the session is still dispatched");
|
|
714
714
|
assert.equal(res.path, "session", "answerItem took the session path");
|
|
715
715
|
});
|
|
716
|
+
|
|
717
|
+
// ===========================================================================
|
|
718
|
+
// is_dm END-TO-END — the Cohort channel_kind is the authoritative signal.
|
|
719
|
+
// ===========================================================================
|
|
720
|
+
|
|
721
|
+
test("enrichItem: Cohort channel_kind decides is_dm (DM yes, GROUP_DM no)", () => {
|
|
722
|
+
// A 1:1 DM skips the election gate; a GROUP_DM is multi-participant and must
|
|
723
|
+
// go through it. Cohort ids are cuids, so the kind is the only reliable signal.
|
|
724
|
+
const dm = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "DM" }, "cohort");
|
|
725
|
+
assert.equal(dm.is_dm, true, "channel_kind DM → is_dm true (skips election)");
|
|
726
|
+
const group = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "GROUP_DM" }, "cohort");
|
|
727
|
+
assert.equal(group.is_dm, false, "channel_kind GROUP_DM → is_dm false (goes through election)");
|
|
728
|
+
const pub = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "public" }, "cohort");
|
|
729
|
+
assert.equal(pub.is_dm, false, "any other explicit kind → not a 1:1 DM");
|
|
730
|
+
// No kind → fall back to the Slack-style id/label prefix heuristics.
|
|
731
|
+
assert.equal(daemon.enrichItem({ channel: "dm/ceo", channel_id: "D123" }, "slack").is_dm, true);
|
|
732
|
+
assert.equal(daemon.enrichItem({ channel: "channel/roadmap", channel_id: "C123" }, "slack").is_dm, false);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("enrichItem: a WIDE-path Cohort 1:1 DM (is_dm:true, NO channel_kind, cuid id) stays is_dm through the whole chain", async () => {
|
|
736
|
+
// REGRESSION. The DEFAULT inbound reader is lib/org/inbound.pullWideInbound,
|
|
737
|
+
// whose projection (lib/org/inbound/project.mjs toMessageEvent) marks a 1:1 DM
|
|
738
|
+
// with is_dm:true + channel_type:"dm" but does NOT emit channel_kind (only the
|
|
739
|
+
// non-default MESSAGING_INBOUND_WIDE=0 narrow reader sets channel_kind). Cohort
|
|
740
|
+
// channel ids are cuids, so the Slack-style "dm/"/"D…" prefix heuristics never
|
|
741
|
+
// fire on them. Before the fix, enrichItem derived is_dm ONLY from an (absent)
|
|
742
|
+
// channel_kind or those (never-matching) heuristics and CLOBBERED the true
|
|
743
|
+
// is_dm to false — routing a genuine 1:1 DM through the group election gate,
|
|
744
|
+
// which then suppressed it. This drives the real chain end to end:
|
|
745
|
+
// toMessageEvent → eventToInboxItem → writeInboxItem → parseInboxItemYaml → enrichItem
|
|
746
|
+
// and asserts is_dm survives as true.
|
|
747
|
+
const { toMessageEvent } = await import("../../lib/org/inbound/project.mjs");
|
|
748
|
+
const { classifyEvent } = await import("../../lib/org/inbound/directedness.mjs");
|
|
749
|
+
const { eventToInboxItem } = await import("../../lib/channels/inbox-item.mjs");
|
|
750
|
+
const { writeInboxItem } = await import("../poller/utils.mjs");
|
|
751
|
+
const { parseInboxItemYaml } = await import("../poller/inbox-scan-poller.mjs");
|
|
752
|
+
|
|
753
|
+
const CUID = "clw9x7k4a0000abcd1234efgh"; // a real cuid: no "D…"/"dm/" prefix to key off
|
|
754
|
+
const cand = classifyEvent({
|
|
755
|
+
seq: 501, at: "2026-08-25T09:00:00.000Z", actor: "M-them",
|
|
756
|
+
family: "messaging", kind: "send", entity_id: "m501",
|
|
757
|
+
payload: { actor: "M-them", channelId: CUID, channelKind: "DM" },
|
|
758
|
+
});
|
|
759
|
+
const facts = { channels: new Map([[CUID, { id: CUID, kind: "DM", name: "them" }]]) };
|
|
760
|
+
const mev = toMessageEvent({
|
|
761
|
+
candidate: cand,
|
|
762
|
+
verdict: { surface: "dm", reason: "dm", directed: true },
|
|
763
|
+
hydrated: { ok: true, text: "ping", from: { id: "M-them", name: "Casey" }, channelId: CUID, channelLabel: "them" },
|
|
764
|
+
me: "M-me",
|
|
765
|
+
facts,
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// The premise the regression rests on: the wide projection marks the DM but
|
|
769
|
+
// does NOT emit channel_kind.
|
|
770
|
+
assert.equal(mev.is_dm, true, "wide-path DM projects is_dm:true");
|
|
771
|
+
assert.equal(mev.channel_type, "dm");
|
|
772
|
+
assert.equal("channel_kind" in mev, false, "the DEFAULT wide reader emits NO channel_kind");
|
|
773
|
+
|
|
774
|
+
// eventToInboxItem carries is_dm through but (correctly) attaches no channel_kind.
|
|
775
|
+
const item = eventToInboxItem(mev);
|
|
776
|
+
assert.equal(item.is_dm, true);
|
|
777
|
+
assert.equal("channel_kind" in item, false);
|
|
778
|
+
|
|
779
|
+
// writeInboxItem → parseInboxItemYaml: the on-disk round-trip the poller does.
|
|
780
|
+
writeInboxItem("cohort", item, AGENT_DIR);
|
|
781
|
+
const dir = join(AGENT_DIR, "state", "inbox", "cohort");
|
|
782
|
+
const file = readdirSync(dir).find((f) => f.includes(item.id) && f.endsWith(".yaml"));
|
|
783
|
+
assert.ok(file, "the inbox item was written to disk");
|
|
784
|
+
const parsed = parseInboxItemYaml(readFileSync(join(dir, file), "utf8"));
|
|
785
|
+
assert.equal(parsed.is_dm, true, "is_dm survives the YAML round-trip");
|
|
786
|
+
assert.equal("channel_kind" in parsed, false, "and still no channel_kind on the wide path");
|
|
787
|
+
|
|
788
|
+
// enrichItem is the last hop before the election gate. It MUST honor the
|
|
789
|
+
// pre-existing boolean is_dm when channel_kind is absent, not clobber it.
|
|
790
|
+
const enriched = daemon.enrichItem(parsed, "cohort");
|
|
791
|
+
assert.equal(enriched.is_dm, true, "the wide-path 1:1 DM stays is_dm — it must SKIP the group election gate");
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// ===========================================================================
|
|
795
|
+
// NO FALLTHROUGH ON PERMANENT FAILURE — a permanent quick-reply send failure
|
|
796
|
+
// (FORBIDDEN_SCOPE) must escalate, not spawn an autonomous session that would
|
|
797
|
+
// post UNRELATED work into a channel the seat may not write to.
|
|
798
|
+
// ===========================================================================
|
|
799
|
+
|
|
800
|
+
test("NO FALLTHROUGH: a PERMANENT quick-reply failure escalates instead of spawning a session", async () => {
|
|
801
|
+
resetState();
|
|
802
|
+
const item = { id: "MSG-PERM", raw_ref: "slack:DPERM:1", service: "slack", channel: "dm/ceo-perm", channel_id: "DPERM0001", is_dm: true, sender: "ceo", subject: "forbidden send", content: "please post this" };
|
|
803
|
+
let dispatched = false;
|
|
804
|
+
const res = await daemon.answerItem(item, "slack", "MSG-PERM", "trace-perm", {
|
|
805
|
+
classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
|
|
806
|
+
isQuickReply: () => true,
|
|
807
|
+
sendQuickResponse: async () => ({ sent: false, permanent: true, code: "FORBIDDEN_SCOPE", error: "FORBIDDEN_SCOPE: blocked by send-gate" }),
|
|
808
|
+
dispatch: () => { dispatched = true; },
|
|
809
|
+
claudeAvailable: () => true,
|
|
810
|
+
});
|
|
811
|
+
assert.equal(dispatched, false, "a permanent send failure must NOT spawn an autonomous session");
|
|
812
|
+
assert.equal(res.path, "quick_reply_permanent");
|
|
813
|
+
assert.match(res.reason, /FORBIDDEN_SCOPE/);
|
|
814
|
+
const naDir = join(AGENT_DIR, "state", "obligations", "needs-attention");
|
|
815
|
+
assert.ok(existsSync(naDir) && readdirSync(naDir).length > 0, "a durable needs-attention escalation was written for an operator");
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("NO FALLTHROUGH: a TRANSIENT quick-reply failure still falls through to a full session", async () => {
|
|
819
|
+
resetState();
|
|
820
|
+
const item = { id: "MSG-TRAN", raw_ref: "slack:DTRAN:1", service: "slack", channel: "dm/ceo-tran", channel_id: "DTRAN0001", is_dm: true, sender: "ceo", subject: "transient send", content: "please post this" };
|
|
821
|
+
let dispatched = false;
|
|
822
|
+
const res = await daemon.answerItem(item, "slack", "MSG-TRAN", "trace-tran", {
|
|
823
|
+
classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
|
|
824
|
+
isQuickReply: () => true,
|
|
825
|
+
sendQuickResponse: async () => ({ sent: false, error: "hq 503 blip" }), // transient: NO permanent flag
|
|
826
|
+
sendHoldingMessage: async () => ({ sent: true, holdingText: "on it" }),
|
|
827
|
+
dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
828
|
+
claudeAvailable: () => true,
|
|
829
|
+
});
|
|
830
|
+
assert.equal(dispatched, true, "a transient failure falls through to the full session, as before");
|
|
831
|
+
assert.equal(res.path, "session");
|
|
832
|
+
});
|
|
@@ -190,7 +190,8 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
|
|
|
190
190
|
const ack = transcript.find((m) => m.kind === "ack");
|
|
191
191
|
assert.ok(ack, "the acknowledgement must go out");
|
|
192
192
|
assert.ok(ack.ms < 1000, `acknowledged in ${ack.ms}ms — the old path budgeted 60,000ms and missed 2 times in 3`);
|
|
193
|
-
assert.match(ack.text, /
|
|
193
|
+
assert.match(ack.text, /on it|looking now|digging in/i);
|
|
194
|
+
assert.ok(ack.text.length <= 40, "the ack is one short human line, not a templated paragraph");
|
|
194
195
|
|
|
195
196
|
const key = assurance.openObligations()[0].key;
|
|
196
197
|
note("obligation opened and acknowledged; session running");
|
|
@@ -248,7 +249,7 @@ test("SCENARIO 3 — a timing-out session tells the human what happened and that
|
|
|
248
249
|
const failure = transcript.filter((m) => m.kind === "failure").at(-1);
|
|
249
250
|
assert.ok(failure, "the human MUST be told; the old code emitted telemetry and nothing else");
|
|
250
251
|
assert.match(failure.text, /ran past its time limit/i);
|
|
251
|
-
assert.match(failure.text, /retrying
|
|
252
|
+
assert.match(failure.text, /retrying now/i);
|
|
252
253
|
assert.equal(assurance.readObligation(key).state, "open", "still owed until the retry resolves");
|
|
253
254
|
note("item left un-processed for redelivery; the debt stays open so the retry is covered too", virtual.v);
|
|
254
255
|
});
|
|
@@ -278,7 +279,7 @@ test("SCENARIO 4 — a crashing session tells the human it failed, stops, and es
|
|
|
278
279
|
|
|
279
280
|
assert.equal(r.willRetry, false, "a spawn failure is permanent — retrying burns another 45 minutes for the same outcome");
|
|
280
281
|
const failure = transcript.filter((m) => m.kind === "failure").at(-1);
|
|
281
|
-
assert.match(failure.text, /couldn't
|
|
282
|
+
assert.match(failure.text, /couldn't finish this/i);
|
|
282
283
|
assert.match(failure.text, /stopped retrying/i);
|
|
283
284
|
assert.match(failure.text, /flagged it so it isn't lost/i);
|
|
284
285
|
assert.match(failure.text, /\?\s*$/, "a dead end ends in a question, so the human has a move");
|
|
@@ -311,16 +311,13 @@ function variant(seed, n) {
|
|
|
311
311
|
/**
|
|
312
312
|
* Trim the classifier's summary into a short topic phrase — casing preserved.
|
|
313
313
|
*
|
|
314
|
-
* This
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* where original casing reads correctly. The stored `rec.summary` flows into
|
|
322
|
-
* progress/failure/interrupted messages and the board-row title too, so the
|
|
323
|
-
* no-recasing rule protects all of them at once.
|
|
314
|
+
* This is NO LONGER interpolated into any message a human reads (the "(CEO
|
|
315
|
+
* asking what machine…)" bleed is gone — see composeAck). It survives only to
|
|
316
|
+
* populate `rec.summary`, which feeds the board-row title and the
|
|
317
|
+
* needs-attention escalation record. Casing is preserved because a summary that
|
|
318
|
+
* starts with an acronym or a name ("CEO", "Q3") must not be mangled in those
|
|
319
|
+
* records either; no local rule can tell "Fix" (safe to downcase) from "CEO"
|
|
320
|
+
* (not), so the phrase is stored exactly as the classifier wrote it.
|
|
324
321
|
*/
|
|
325
322
|
function topicClause(classResult, item) {
|
|
326
323
|
const raw = (classResult && classResult.summary) || (item && item.subject) || "";
|
|
@@ -329,84 +326,49 @@ function topicClause(classResult, item) {
|
|
|
329
326
|
return t;
|
|
330
327
|
}
|
|
331
328
|
|
|
332
|
-
/** Rough, honest expectation. Derived from the measured distribution of real
|
|
333
|
-
* sessions, not invented: sonnet work clusters near a minute, opus work near
|
|
334
|
-
* fifteen. Phrased as a range because it IS a range. */
|
|
335
|
-
export function expectedWindow(classResult) {
|
|
336
|
-
const model = classResult && classResult.model;
|
|
337
|
-
const priority = classResult && classResult.priority;
|
|
338
|
-
if (model === "opus" || priority === "critical") return "usually within 10-20 minutes";
|
|
339
|
-
return "usually within a few minutes";
|
|
340
|
-
}
|
|
341
|
-
|
|
342
329
|
/**
|
|
343
|
-
* The acknowledgement
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
330
|
+
* The acknowledgement — sent ONLY when genuinely long, multi-step work is about
|
|
331
|
+
* to run (a directly-answerable question is answered in the turn on the quick
|
|
332
|
+
* path, never acked). So it is one plain line, the way a colleague types it
|
|
333
|
+
* before they get to work: no aside, no filler, no promise-of-followup
|
|
334
|
+
* boilerplate. The failure notices, interim updates and stale sweep are what
|
|
335
|
+
* actually keep the human informed — the ack does not need to narrate them.
|
|
336
|
+
*
|
|
337
|
+
* The classifier's summary is DELIBERATELY not shown here. It used to ride in a
|
|
338
|
+
* parenthetical aside, which leaked the daemon's internal framing to the
|
|
339
|
+
* requester verbatim — "(CEO asking what machine/infrastructure is being used)"
|
|
340
|
+
* reached a real human. The summary is still stored on the obligation for the
|
|
341
|
+
* board row and escalations; it just never appears in a message a person reads.
|
|
355
342
|
*/
|
|
356
|
-
export function composeAck(item
|
|
357
|
-
const topic = topicClause(classResult, item);
|
|
358
|
-
const aside = topic ? ` (${topic})` : "";
|
|
343
|
+
export function composeAck(item) {
|
|
359
344
|
const v = variant(obligationKey(item) || (item && item.sender), 3);
|
|
360
|
-
|
|
361
|
-
`No problem — I'm looking into this now${aside}.`,
|
|
362
|
-
`Got it — I'm on it now${aside}.`,
|
|
363
|
-
`Understood — I'm digging into this now${aside}.`,
|
|
364
|
-
];
|
|
365
|
-
return `${openers[v]} I want to get this right, so I'll come back to you here with a full answer — ${expectedWindow(classResult)} — and you'll hear from me either way.`;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* The topic, set off in parentheses.
|
|
370
|
-
*
|
|
371
|
-
* It is NOT interpolated as the object of a preposition, because the classifier's
|
|
372
|
-
* summary is as often a verb phrase ("fix the issues and push them") as a noun
|
|
373
|
-
* phrase ("July spend reconciliation") — and "Still working on fix the issues"
|
|
374
|
-
* is the kind of sentence that tells a reader they are talking to a machine.
|
|
375
|
-
* Parentheses read correctly for both.
|
|
376
|
-
*/
|
|
377
|
-
function topicAside(rec) {
|
|
378
|
-
return rec && rec.summary ? ` (${rec.summary})` : "";
|
|
345
|
+
return ["On it.", "Looking now.", "On it — digging in now."][v];
|
|
379
346
|
}
|
|
380
347
|
|
|
381
|
-
/** An interim update. Says how long it has been and that the work is live
|
|
382
|
-
*
|
|
383
|
-
* fires at PROGRESS_AFTER_MS (5 min), squarely inside the 10-20 minute window
|
|
384
|
-
* the acknowledgement promised for opus work minutes earlier — a claim the
|
|
385
|
-
* same human could see was false. */
|
|
348
|
+
/** An interim update. Says how long it has been and that the work is live —
|
|
349
|
+
* short, no internal topic aside, no filler. */
|
|
386
350
|
export function composeProgress(rec, o = {}) {
|
|
387
351
|
const now = Number.isFinite(o.now) ? o.now : Date.now();
|
|
388
352
|
const mins = Math.max(1, Math.round((now - (rec.openedAt || now)) / 60_000));
|
|
389
|
-
const topic = topicAside(rec);
|
|
390
353
|
if ((rec.progressSent || 0) === 0) {
|
|
391
|
-
return `Still
|
|
354
|
+
return `Still on this — ${mins} minutes in. I'll come back as soon as I've got something.`;
|
|
392
355
|
}
|
|
393
|
-
return `
|
|
356
|
+
return `Still going — ${mins} minutes in. I'll follow up the moment it's done.`;
|
|
394
357
|
}
|
|
395
358
|
|
|
396
359
|
/**
|
|
397
360
|
* The failure notice. Says WHAT HAPPENED and WHAT HAPPENS NEXT, in that order,
|
|
398
361
|
* because those are the two things the human is missing when a session dies.
|
|
399
|
-
*
|
|
400
|
-
* ends without a next step.
|
|
362
|
+
* Terse and human: never blames the human, never hides behind "an error
|
|
363
|
+
* occurred", never ends without a next step, and never shows internal framing.
|
|
401
364
|
*/
|
|
402
365
|
export function composeFailure(rec, o = {}) {
|
|
403
366
|
const f = o.failure || {};
|
|
404
|
-
const topic = topicAside(rec);
|
|
405
367
|
const cause = f.human || "the working session ended unexpectedly";
|
|
406
368
|
if (o.willRetry) {
|
|
407
|
-
return `
|
|
369
|
+
return `Hit a problem — ${cause}. Retrying now; if it fails again I'll come straight back rather than leave you waiting.`;
|
|
408
370
|
}
|
|
409
|
-
return `
|
|
371
|
+
return `Couldn't finish this — ${cause}. I've stopped retrying and flagged it so it isn't lost. Want me to try a narrower version, hand it off, or leave it with you?`;
|
|
410
372
|
}
|
|
411
373
|
|
|
412
374
|
/**
|
|
@@ -416,20 +378,18 @@ export function composeFailure(rec, o = {}) {
|
|
|
416
378
|
* `sent`. This message is what the human gets instead of that silence.
|
|
417
379
|
*/
|
|
418
380
|
export function composeSilentSuccess(rec, o = {}) {
|
|
419
|
-
const topic = topicAside(rec);
|
|
420
381
|
const tail = (o.finalText || "").trim();
|
|
421
|
-
const head = `
|
|
382
|
+
const head = `Finished this but didn't get a reply out to you — my fault.`;
|
|
422
383
|
if (tail) {
|
|
423
384
|
const excerpt = tail.length > 900 ? `${tail.slice(0, 900).trimEnd()}…` : tail;
|
|
424
|
-
return `${head} Here's where I got to:\n\n${excerpt}
|
|
385
|
+
return `${head} Here's where I got to:\n\n${excerpt}`;
|
|
425
386
|
}
|
|
426
|
-
return `${head} I don't have a clean result to show you,
|
|
387
|
+
return `${head} I don't have a clean result to show you, though. Want me to run it again?`;
|
|
427
388
|
}
|
|
428
389
|
|
|
429
390
|
/** A session interrupted by the daemon itself dying/restarting. */
|
|
430
|
-
export function composeInterrupted(
|
|
431
|
-
|
|
432
|
-
return `Heads up — my working session on this${topic} was interrupted before it finished (my end restarted). I've put it back in the queue and I'm picking it up again now; I'll come back to you with the answer.`;
|
|
391
|
+
export function composeInterrupted() {
|
|
392
|
+
return `Heads up — my session on this was interrupted before it finished (my end restarted). I've picked it back up; I'll come back with the answer.`;
|
|
433
393
|
}
|
|
434
394
|
|
|
435
395
|
// ---------------------------------------------------------------------------
|