Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/read-receipt-min-age.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 24 additions & 3 deletions packages/core/src/attention-inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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.
Expand All @@ -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',
Expand Down
55 changes: 55 additions & 0 deletions packages/core/test/attention-inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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, {
Expand Down
Loading