@cohortapp/agent-sdk 2.14.0 → 2.16.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.
@@ -466,9 +466,9 @@ async function sendGmailResponse(item, text) {
466
466
  * and hq's server-side dedup swallows every message after the first, which
467
467
  * would turn "never silent" into "acknowledged once and then silent forever".
468
468
  *
469
- * It must be distinct per MESSAGE, not per kind: two progress updates that both
470
- * key on "progress" are one message as far as hq is concerned. Callers that can
471
- * emit a kind more than once pass an ordinal (`progress-1`, `progress-2`). A
469
+ * It must be distinct per MESSAGE, not per kind: two failure notices that both
470
+ * key on "failure" are one message as far as hq is concerned. Callers that can
471
+ * emit a kind more than once pass an ordinal (`failure-1`, `failure-2`). A
472
472
  * genuine transport RETRY deliberately reuses the same suffix — that is the
473
473
  * dedup doing its job.
474
474
  */
@@ -636,7 +636,10 @@ async function sendCohortEntityComment(route, text, o = {}) {
636
636
  * @param {object} item daemon inbox item
637
637
  * @param {string} text exactly what the human will read
638
638
  * @param {object} [o]
639
- * @param {string} [o.kind] receipt kind: "ack" | "progress" | "failure" | "reply"
639
+ * @param {string} [o.kind] receipt kind: "reply" (an ANSWER) or one of the
640
+ * courtesy kinds "ack" | "plan" | "notice" | "failure". Only "reply"
641
+ * discharges an obligation — see `lib/comms/receipts.NON_ANSWER_KINDS`.
642
+ * ("progress" is retired: nothing emits it since 2026-09-12.)
640
643
  * @param {string[]} [o.mentions] org member ids to @-tag (cohort room sends only)
641
644
  * @param {function} [o.fetchImpl] test seam
642
645
  * @param {function} [o.callImpl] test seam — the org RPC (entity-thread routes)
@@ -836,11 +836,18 @@ function buildBacklogContext(queueItem) {
836
836
  *
837
837
  * @param {object} item - The inbox item (sender, content, channel, service, subject, thread_context, etc.)
838
838
  * @param {object} classResult - Classifier output (priority, action, model, summary, category)
839
- * @param {object} options - { type: "inbox" | "backlog", queueItem?: object }
839
+ * @param {object} options - { type, queueItem?, holdingMessage?, holdingSent?, interimAlreadySent? }
840
+ * `holdingMessage` the interim's TEXT, when this process composed it.
841
+ * `holdingSent` false ⇒ it was composed but did not land.
842
+ * `interimAlreadySent` true ⇒ an interim reached the sender but its text is
843
+ * not available here (the re-delivery / retry path,
844
+ * where the debt is already open with interimSaid). It
845
+ * is what stops the no-contact block asserting silence
846
+ * at a session whose human has already been spoken to.
840
847
  * @returns {string} Prompt string ready for claude --print
841
848
  */
842
849
  export async function buildPrompt(item, classResult, options = {}) {
843
- const { type = "inbox", queueItem, holdingMessage, holdingSent } = options;
850
+ const { type = "inbox", queueItem, holdingMessage, holdingSent, interimAlreadySent } = options;
844
851
  // Only `false` — an explicit "the send failed" from the caller — flips the
845
852
  // framing. Callers that pass no flag keep the historical assertion, so this
846
853
  // cannot silently downgrade a genuinely delivered acknowledgement.
@@ -895,16 +902,31 @@ export async function buildPrompt(item, classResult, options = {}) {
895
902
  // 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
896
903
  // This is the most critical instruction in the prompt: prevents double-replies.
897
904
  // We repeat it at section 7a as well, immediately before the action block.
905
+ //
906
+ // WHEN EACH BRANCH IS REACHED (2026-09-12). The daemon opens the obligation
907
+ // and says NOTHING, so on the ordinary inbound path `holdingMessage` is null.
908
+ // The first two branches exist for the case where an interim genuinely did go
909
+ // out WITH ITS TEXT IN HAND before the prompt was built: the PLAN tier, whose
910
+ // one message is posted as the first step of the work. Do not delete them as
911
+ // dead; they are the correct rendering of a fact that is about to be common.
912
+ //
913
+ // The third branch is for an interim that went out whose TEXT THIS PROCESS
914
+ // DOES NOT HAVE — a re-delivery of an item whose debt is already open with
915
+ // `interimSaid: true`, which `openAndAcknowledge` answers with
916
+ // `{acked:true, ackText:null}`. Before it existed, that case fell into FIRST
917
+ // CONTACT and told a RETRY session that a human who had already received a
918
+ // holding line AND a "Hit a problem — … Retrying now" had heard nothing.
898
919
  if (holdingMessage && holdingDelivered) {
899
920
  parts.push("===== STOP — READ THIS FIRST =====");
900
921
  parts.push(`A HOLDING MESSAGE has ALREADY been sent to the sender by the daemon. The exact text was:`);
901
922
  parts.push(` "${holdingMessage}"`);
902
923
  parts.push("");
924
+ parts.push("That was the ONE interim this ask gets. The sender will hear nothing else until you deliver something substantive, and nothing in the daemon will fill the gap — the timed progress updates were deleted on 2026-09-12 because a message derived from elapsed minutes carries no information.");
903
925
  parts.push("Your job is to deliver the FULL substantive response or complete the actual work — NOT to acknowledge again.");
904
926
  parts.push("- Do NOT start your reply with 'Got it', 'On it', 'Looking into this', 'Will get back to you', 'Thanks for reaching out', or any similar acknowledgment phrase.");
905
927
  parts.push("- Do NOT echo what the user asked — they already received the holding note confirming receipt.");
906
928
  parts.push("- Open with the actual answer, the actual draft, or the actual finding. Be direct.");
907
- parts.push("- If after investigation you still cannot deliver a substantive response and need more time, send a SECOND-LEVEL UPDATE (specific blocker, ETA, what you need from the user) — never a generic 'still looking into it'.");
929
+ parts.push("- If the work runs long, say nothing OR say something with CONTENT in it — a specific blocker, a partial finding, what you need from the user. Never 'still looking into it', never a status line whose only content is how long it has been.");
908
930
  parts.push("===== END WARNING =====");
909
931
  parts.push("");
910
932
  } else if (holdingMessage) {
@@ -926,6 +948,40 @@ export async function buildPrompt(item, classResult, options = {}) {
926
948
  parts.push("- If you do NOT, do not let the item evaporate. Record the undeliverable ask and escalate it to the operator — silence plus a closed inbox item is how a request disappears.");
927
949
  parts.push("===== END WARNING =====");
928
950
  parts.push("");
951
+ } else if (interimAlreadySent && type === "inbox" && item) {
952
+ // AN INTERIM WENT OUT AND THIS PROCESS CANNOT QUOTE IT. See the note above:
953
+ // this is the re-delivery / retry path. What it must NOT say is "the sender
954
+ // has heard nothing", which is the specific falsehood the FIRST CONTACT
955
+ // block would assert here.
956
+ parts.push("===== ALREADY IN CONTACT =====");
957
+ parts.push("A short interim about this item has already reached the sender. This process no longer holds its text, so do not quote it or guess at it.");
958
+ parts.push("That was the ONE interim this ask gets. Nothing else will be sent on your behalf — the timed progress updates were deleted on 2026-09-12 because a message derived from elapsed minutes carries no information.");
959
+ parts.push("- Deliver the substantive response. Do NOT acknowledge receipt again.");
960
+ parts.push("- Do not apologise for the delay and do not recap what they asked.");
961
+ parts.push("- If the work runs long, say nothing OR say something with CONTENT in it — a specific blocker, a partial finding, what you need from the sender.");
962
+ parts.push("===== END =====");
963
+ parts.push("");
964
+ } else if (type === "inbox" && item) {
965
+ // NOTHING HAS BEEN SENT, AND THAT IS THE DEFAULT. Gated on `inbox` because a
966
+ // backlog item is work the agent gave itself — there is no sender on the
967
+ // other end of it and this block would be addressed to nobody.
968
+ //
969
+ // WHAT THIS BLOCK MAY AND MAY NOT CLAIM. It is built at dispatch time, t≈0.
970
+ // It can state a FACT about the past — nothing has gone out yet — and it
971
+ // must not state a PREDICTION about the future, which is what "your reply is
972
+ // the first thing they will read" was: measured p50 session duration is
973
+ // 14.7 min against an ACK_AFTER_MS of 90 s, so for most sessions the sweep
974
+ // will in fact have posted one line before the reply lands. A prompt that
975
+ // tells a session it is the first voice in the room, when a holding line
976
+ // lands in front of it, produces exactly the double-contact the first two
977
+ // blocks exist to prevent — in the other direction.
978
+ parts.push("===== NO CONTACT YET =====");
979
+ parts.push("Nothing has been sent to the sender about this item so far. If this runs long, the daemon may post at most ONE short holding line before your reply — you will not be told if it does, and it is the only one this ask gets.");
980
+ parts.push("- Open with the answer, the draft or the finding. Do not open by acknowledging receipt, and do not apologise for the delay.");
981
+ parts.push("- Do not write as though a conversation is already underway — no 'as I mentioned', no 'following up on my earlier note'.");
982
+ parts.push("- If you cannot deliver something substantive, say the specific thing that is blocking you. Never a bare 'looking into it'.");
983
+ parts.push("===== END =====");
984
+ parts.push("");
929
985
  }
930
986
 
931
987
  // 2. Session context
@@ -987,11 +1043,14 @@ export async function buildPrompt(item, classResult, options = {}) {
987
1043
  // If a holding message was already sent, prepend a second reminder so the
988
1044
  // action block is unambiguous about not re-acknowledging.
989
1045
  if (holdingMessage && holdingDelivered) {
990
- parts.push("REMINDER: A holding message was already sent (see top of prompt). The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
1046
+ parts.push("REMINDER: A holding message was already sent (see top of prompt), and it was the only one this ask gets. The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
991
1047
  parts.push("");
992
1048
  } else if (holdingMessage) {
993
1049
  parts.push("REMINDER: The acknowledgement for this item FAILED to send (see top of prompt) — the sender has heard nothing. The action below describes WHAT to do; carry it out knowing this is still first contact, and escalate rather than close the item silently if you cannot reach them.");
994
1050
  parts.push("");
1051
+ } else if (interimAlreadySent) {
1052
+ parts.push("REMINDER: An interim about this item already reached the sender (see top of prompt), and it was the only one this ask gets. The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
1053
+ parts.push("");
995
1054
  }
996
1055
  parts.push(actionBlock);
997
1056
  parts.push("");
@@ -267,6 +267,74 @@ test("buildPrompt does NOT claim delivery when the acknowledgement failed to sen
267
267
  assert.ok(prompt.includes("Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way."));
268
268
  });
269
269
 
270
+ // ---------------------------------------------------------------------------
271
+ // THE THIRD AND FOURTH STATES: an interim whose text this process does not
272
+ // hold, and no contact at all. Both were one block before 2026-09-12, and that
273
+ // block asserted a fact that is false on most dispatches.
274
+ // ---------------------------------------------------------------------------
275
+
276
+ test("REGRESSION: a retry session is NOT told the sender has heard nothing", async () => {
277
+ // The reachable-today path. An item re-delivered while its debt is open with
278
+ // interimSaid:true makes openAndAcknowledge return {acked:true, ackText:null}:
279
+ // holdingMessage is null, so both holding blocks are skipped, and before this
280
+ // fix the no-contact block rendered — telling a RETRY session that a human who
281
+ // had already received a holding line AND a "Hit a problem — … Retrying now"
282
+ // had heard nothing. On main this path produced no block at all, so the false
283
+ // claim was introduced by the acknowledgement-discipline change itself.
284
+ const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox", interimAlreadySent: true });
285
+ assert.match(prompt, /ALREADY IN CONTACT/);
286
+ assert.match(prompt, /already reached the sender/);
287
+ assert.ok(!/heard nothing/.test(prompt), "must not assert silence at a sender who has been spoken to");
288
+ assert.ok(!/NO CONTACT YET/.test(prompt), "the no-contact block must not render alongside it");
289
+ // It must not invent the text it does not have.
290
+ assert.match(prompt, /do not quote it or guess at it/);
291
+ // And the second reminder, immediately before the action block, agrees.
292
+ assert.match(prompt, /REMINDER: An interim about this item already reached the sender/);
293
+ });
294
+
295
+ test("the no-contact block states a FACT about the past, not a PREDICTION about the future", async () => {
296
+ // What it said before: "your reply is the first thing they will read". That is
297
+ // false on the majority of dispatches — the sweep posts one interim at
298
+ // ACK_AFTER_MS (90 s) while measured p50 session duration is 14.7 min — and a
299
+ // session told it is the first voice in the room, with a holding line landing
300
+ // in front of it, produces exactly the double-contact the delivered-holding
301
+ // block exists to prevent, in the other direction.
302
+ const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
303
+ assert.match(prompt, /NO CONTACT YET/);
304
+ assert.match(prompt, /Nothing has been sent to the sender about this item so far/);
305
+ assert.ok(
306
+ !/first thing they will read/.test(prompt),
307
+ "the prompt may not predict that no interim will land before the reply",
308
+ );
309
+ // It must say the opposite instead: one may yet go out, silently.
310
+ assert.match(prompt, /may post at most ONE short holding line/);
311
+ assert.ok(!/ALREADY IN CONTACT/.test(prompt));
312
+ });
313
+
314
+ test("a delivered holding message outranks the interim-already-sent flag", async () => {
315
+ // When the text IS in hand, quoting it is strictly better than saying it
316
+ // exists — so the ordering of the branches is pinned rather than incidental.
317
+ const prompt = await buildPrompt(ITEM, CLASS, {
318
+ type: "inbox",
319
+ holdingMessage: "Give me a few minutes on the July reconciliation — I'll have the variances.",
320
+ holdingSent: true,
321
+ interimAlreadySent: true,
322
+ });
323
+ assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
324
+ assert.ok(!/ALREADY IN CONTACT/.test(prompt));
325
+ assert.ok(!/NO CONTACT YET/.test(prompt));
326
+ });
327
+
328
+ test("neither block reaches a backlog item — there is no sender on the other end", async () => {
329
+ const prompt = await buildPrompt(null, CLASS, {
330
+ type: "backlog",
331
+ queueItem: { id: "q-1", title: "Sweep the stale worktrees", status: "open" },
332
+ interimAlreadySent: true,
333
+ });
334
+ assert.ok(!/ALREADY IN CONTACT/.test(prompt));
335
+ assert.ok(!/NO CONTACT YET/.test(prompt));
336
+ });
337
+
270
338
  test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)", async () => {
271
339
  const prompt = await buildPrompt(ITEM, CLASS, {
272
340
  type: "inbox",
@@ -993,9 +993,14 @@ export async function sendQuickResponse(item, classResult, routed = null) {
993
993
  * via the cheapest model under a tight (~5s, env-tunable) cap, and on ANY
994
994
  * generation failure this function sends NOTHING and says so in the log. The
995
995
  * durability story is unchanged — the caller opened the obligation before
996
- * calling this, the assurance sweep retries generation once within
997
- * ASSURANCE_ACK_GRACE_MS, and the progress/failure notices still guarantee the
998
- * human is never left in long-term silence.
996
+ * calling this, and the assurance sweep owns whether a line goes out at all.
997
+ *
998
+ * NOTE (2026-09-12): the daemon no longer calls this from the acknowledgement
999
+ * path. `assurance.openAndAcknowledge` says NOTHING at open time; the sweep
1000
+ * emits at most one interim per obligation, past ASSURANCE_ACK_AFTER_MS and
1001
+ * inside the per-room budget. The timed progress updates are deleted. The
1002
+ * failure, stale and interrupted notices still guarantee the human is never
1003
+ * left in long-term silence.
999
1004
  *
1000
1005
  * @returns {{ sent: boolean, holdingText: string|null }} result
1001
1006
  */