From 2d75f9073d4ef413d7df85f30fabd430060262ac Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Sat, 25 Apr 2026 13:56:24 +0200 Subject: [PATCH] feat(core): add 5-minute min-age gate to read receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildAttentionInbox` previously surfaced every `message_read` sibling the moment it was written, which meant the sender's preface lit up with "B read your message — no reply yet" 30 seconds after B opened the message. That's noise: B is almost certainly still composing a reply. New `read_receipt_min_age_ms` option (default 5 minutes) suppresses receipts younger than the threshold. Tests and hot-debug paths can pass `read_receipt_min_age_ms: 0` to opt out. The existing receipt test does this; a new ripening test asserts the gate behavior using the existing `now` injection point so the assertion doesn't race the wall clock. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/read-receipt-min-age.md | 15 ++++++ packages/core/src/attention-inbox.ts | 27 +++++++++-- packages/core/test/attention-inbox.test.ts | 55 ++++++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 .changeset/read-receipt-min-age.md diff --git a/.changeset/read-receipt-min-age.md b/.changeset/read-receipt-min-age.md new file mode 100644 index 0000000..5e0838d --- /dev/null +++ b/.changeset/read-receipt-min-age.md @@ -0,0 +1,15 @@ +--- +"@colony/core": patch +--- + +Suppress fresh read receipts in `attention_inbox` until they ripen. + +`buildAttentionInbox` now filters out `message_read` siblings that are +younger than `read_receipt_min_age_ms` (default 5 minutes). The receipt +exists in storage immediately, but the inbox only surfaces it once +"the recipient had time to respond and didn't" is honest signal — +otherwise the sender's preface gets a "follow up?" hint every turn the +recipient is still typing. + +The min-age window is configurable per call so tests and hot-debug +sessions can pass `read_receipt_min_age_ms: 0` to opt out. diff --git a/packages/core/src/attention-inbox.ts b/packages/core/src/attention-inbox.ts index 6d6b32c..5918b3f 100644 --- a/packages/core/src/attention-inbox.ts +++ b/packages/core/src/attention-inbox.ts @@ -156,12 +156,22 @@ export interface AttentionInboxOptions { * out so a long-running session doesn't accumulate stale "B read your * message 3 days ago" hints. Default 6h. */ read_receipt_window_ms?: number; + /** + * Minimum age (ms) before a read receipt becomes surface-worthy. Receipts + * younger than this are suppressed because "B read 30s ago, no reply yet" + * is noise — B is still thinking. The receipt only carries signal once + * "they had time to respond and didn't" is meaningful. Default 5m; + * shorter windows make sense in tests, longer windows make sense for + * heavyweight reviews where 5m is still too eager. Set to 0 to disable. + */ + read_receipt_min_age_ms?: number; read_receipt_limit?: number; } const DEFAULT_RECENT_CLAIM_WINDOW_MS = 15 * 60_000; const DEFAULT_RECENT_CLAIM_LIMIT = 20; const DEFAULT_READ_RECEIPT_WINDOW_MS = 6 * 60 * 60_000; +const DEFAULT_READ_RECEIPT_MIN_AGE_MS = 5 * 60_000; const DEFAULT_READ_RECEIPT_LIMIT = 20; /** @@ -288,8 +298,12 @@ function coalesceMessages(messages: InboxMessage[]): CoalescedMessageGroup[] { * Walk task observation rows of kind 'message_read' and surface the ones * whose metadata names the calling session as the original sender. Drops * a receipt when the underlying message has since been replied to (the - * reply is the stronger signal) or when the receipt is older than - * `read_receipt_window_ms`. + * reply is the stronger signal), when the receipt is older than + * `read_receipt_window_ms`, or when the receipt is *younger* than + * `read_receipt_min_age_ms`. The min-age filter prevents the noisy + * "B read 30s ago, no reply yet" case where the recipient is still + * formulating a response — the receipt only carries signal once enough + * time has passed that "could have replied and didn't" is honest. */ function collectReadReceipts( store: MemoryStore, @@ -298,8 +312,10 @@ function collectReadReceipts( now: number, ): ReadReceipt[] { const window = opts.read_receipt_window_ms ?? DEFAULT_READ_RECEIPT_WINDOW_MS; + const minAge = opts.read_receipt_min_age_ms ?? DEFAULT_READ_RECEIPT_MIN_AGE_MS; const cap = opts.read_receipt_limit ?? DEFAULT_READ_RECEIPT_LIMIT; const since = now - window; + const ripeBefore = now - minAge; const out: ReadReceipt[] = []; for (const task_id of taskIds) { const rows = store.storage.taskObservationsByKind(task_id, 'message_read', cap * 2); @@ -323,6 +339,11 @@ function collectReadReceipts( if (meta.kind !== 'message_read') continue; if (meta.original_sender_session_id !== opts.session_id) continue; if (typeof meta.read_message_id !== 'number') continue; + const readAt = typeof meta.ts === 'number' ? meta.ts : r.ts; + // Min-age gate: the receipt is real but too fresh to act on. + // Recipient might still be typing; surfacing now turns every + // mark_read into a "follow up?" prompt for the sender. + if (readAt > ripeBefore) continue; // Drop when the original message has since been replied to. The // reply already reaches the sender as a fresh inbox entry, so a // surviving receipt would be redundant noise. @@ -337,7 +358,7 @@ function collectReadReceipts( out.push({ task_id, read_message_id: meta.read_message_id, - read_at: typeof meta.ts === 'number' ? meta.ts : r.ts, + read_at: readAt, read_by_session_id: meta.read_by_session_id ?? r.session_id, read_by_agent: meta.read_by_agent ?? '', urgency: meta.urgency ?? 'fyi', diff --git a/packages/core/test/attention-inbox.test.ts b/packages/core/test/attention-inbox.test.ts index da40e35..4eda61a 100644 --- a/packages/core/test/attention-inbox.test.ts +++ b/packages/core/test/attention-inbox.test.ts @@ -239,10 +239,14 @@ describe('buildAttentionInbox', () => { }); thread.markMessageRead(id, 'codex'); + // Disable the min-age gate for this assertion. Without it, the + // receipt is suppressed for the first 5m so the test would race the + // wall clock; the next test covers the gating semantics directly. const senderInbox = buildAttentionInbox(store, { session_id: 'claude', agent: 'claude', task_ids: [thread.task_id], + read_receipt_min_age_ms: 0, }); expect(senderInbox.read_receipts).toHaveLength(1); expect(senderInbox.read_receipts[0]?.read_message_id).toBe(id); @@ -261,10 +265,61 @@ describe('buildAttentionInbox', () => { session_id: 'claude', agent: 'claude', task_ids: [thread.task_id], + read_receipt_min_age_ms: 0, }); expect(after.read_receipts).toHaveLength(0); }); + it('suppresses fresh read receipts until the min-age window passes', () => { + seed('claude', 'codex'); + const thread = TaskThread.open(store, { + repo_root: '/r', + branch: 'feat/inbox-receipt-ripening', + session_id: 'claude', + }); + thread.join('claude', 'claude'); + thread.join('codex', 'codex'); + const id = thread.postMessage({ + from_session_id: 'claude', + from_agent: 'claude', + to_agent: 'codex', + content: 'please review', + urgency: 'needs_reply', + }); + thread.markMessageRead(id, 'codex'); + + // Default 5m gate: the receipt exists in storage but is too fresh + // to surface — recipient might still be typing. + const fresh = buildAttentionInbox(store, { + session_id: 'claude', + agent: 'claude', + task_ids: [thread.task_id], + }); + expect(fresh.read_receipts).toHaveLength(0); + + // Advancing the clock past the gate ripens the receipt. We pass + // `now` rather than mock Date.now so the assertion is explicit. + const ripe = buildAttentionInbox(store, { + session_id: 'claude', + agent: 'claude', + task_ids: [thread.task_id], + now: Date.now() + 6 * 60_000, + }); + expect(ripe.read_receipts).toHaveLength(1); + expect(ripe.read_receipts[0]?.read_message_id).toBe(id); + + // Custom shorter gate works too — a 1-second window surfaces the + // receipt almost immediately, which is what tests / hot debug + // sessions want. + const custom = buildAttentionInbox(store, { + session_id: 'claude', + agent: 'claude', + task_ids: [thread.task_id], + read_receipt_min_age_ms: 0, + }); + expect(custom.read_receipts).toHaveLength(1); + }); + it('returns the quiet-inbox next_action hint when nothing is pending', () => { seed('codex'); const thread = TaskThread.open(store, {