@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.
@@ -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
- why.push(`${base.actor} has woken me ${hits} time(s) in the flood window (limit ${floodLimit}) → batch the reply`);
396
- return finish(base, "schedule", "actor_flood", why, o, { observeOnly, batched: true });
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
- const d = run({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT }) });
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 = run({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT - 1 }) });
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
- items.push(dm({ id: `cohort-msg_${i}`, raw_ref: `cohort:dm:msg_${i}:${100 + i}`, thread_id: `t_${i}`, channel_id: `c_${i}` }));
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);
@@ -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.0",
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
- const isDm = channelStr.startsWith("dm/") || channelId.startsWith("D");
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
- // If quick reply failed to send or was blocked by validation, fall through to dispatch a full session
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, /either way/i);
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 it now/i);
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 get this done/i);
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 used to lowercase the first letter so the phrase could sit mid-sentence.
315
- * Applied to a summary that starts with an acronym or a name, that manufactures
316
- * a typo the requester reads as the agent's own: "Picking up: cEO checking in
317
- * on work progress." went to a real human, first letter of "CEO" folded. No
318
- * local rule can tell "Fix" (safe to downcase) from "CEO", "Q3" or a person's
319
- * name (not safe), so the phrase is taken exactly as the classifier wrote it, and
320
- * every slot that renders it — the parentheses here and in topicAside — is one
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. Two sentences, written the way a colleague would type
344
- * them, and — critically — it commits to coming back "either way". That clause
345
- * is a promise the rest of this module now actually keeps: failure notices,
346
- * interim updates and the stale sweep all exist so it is never a lie. The time
347
- * window is expectedWindow's — measured, not invented.
348
- *
349
- * The topic rides in parentheses at the end of the opener, never spliced into
350
- * the sentence: the summary is an arbitrary phrase in arbitrary case, and the
351
- * previous template — `Picking up: <lowercased summary>. It needs a bit of
352
- * work, …` — showed the daemon's internal framing to the requester and mangled
353
- * acronym-initial summaries on top (see topicClause). "Picking up:" is triage
354
- * vocabulary; a human answering a human does not narrate their queue.
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, classResult = {}) {
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
- const openers = [
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
- * The first update no longer claims "it's taking longer than I expected": it
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 working on this${topic} — ${mins} minutes in. Nothing's stuck; I'll come back as soon as I have something worth sending.`;
354
+ return `Still on this — ${mins} minutes in. I'll come back as soon as I've got something.`;
392
355
  }
393
- return `Quick check-in: still on this${topic}, ${mins} minutes in. I haven't forgotten it — I'll follow up the moment it's done, or tell you if I can't finish it.`;
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
- * Never blames the human, never hides behind "an error occurred", and never
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 `I hit a problem with this${topic} — ${cause}. I'm retrying it now (attempt ${(rec.attempts || 1) + 1} of ${RETRY_MAX + 1}); if it fails again I'll come straight back to you rather than leave you waiting.`;
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 `I couldn't get this done${topic} — ${cause}. I've stopped retrying so I'm not silently burning time on it, and I've flagged it so it isn't lost. Do you want me to try a narrower version of this, hand it to someone else, or leave it with you?`;
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 = `I finished working on this${topic} but didn't get a reply out to you — that's my fault, not yours.`;
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}\n\nIf that doesn't answer it, say so and I'll take another run at it.`;
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, so I'd rather say that than pretend otherwise. Want me to run it again?`;
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(rec) {
431
- const topic = topicAside(rec);
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
  // ---------------------------------------------------------------------------