fix(channels): count inbound client messages on the Sharing-tab roster (#1533)#1554
Merged
Conversation
#1533) The roster showed `message_count = 0` for every external client, `last_active` was frozen at `/login` time, and clients who never ran `/login` never appeared at all. All three share one cause: `get_or_create_chat_link` and `increment_message_count` (Telegram + WhatsApp) had zero callers outside the `database.py` facade. The shared inbound path writes `public_chat_sessions`, a different table, so `*_chat_links` rows were only ever created by `set_*_verified_email` — the `/login` flow. Note: the issue's Context paragraph states that `last_active` "is touched on inbound traffic via `get_or_create_chat_link`". It is not — that function was dead, and returns early for an existing row without writing anything. Revive the write path behind a default-no-op `ChannelAdapter.record_inbound_activity` hook, called once per delivered DM from `ChannelMessageRouter._handle_message_inner` at step 5c: after the access gate, so an unauthenticated stranger who messages the bot cannot create unbounded chat-link rows, and skipped for groups, since a chat link is keyed by (binding, user) and counting group traffic would list members who never DM'd the agent. Telegram and WhatsApp override it; Slack and VoIP inherit the no-op (Invariant #9). The call is best-effort — a counter write never blocks message processing. Replace `get_or_create` + `increment` with one atomic `INSERT … ON CONFLICT DO UPDATE` (`record_inbound`), which removes a cross-worker SELECT-then-INSERT race under `--workers 2`, halves the writes per message, and refreshes a stale display name via `COALESCE(excluded, existing)`. Since `increment_message_count` was `last_active`'s only live writer, one call fixes the count and the timestamp together. Delete the now-dead methods, their facade delegations, and the `_row_to_chat_link` helper they alone called. Historical counts are not backfilled; the roster's "Messages" header says so. Tests: dual-backend (SQLite + PostgreSQL) roster read-back 0->1->2, `last_active` advance, username backfill, and `/login` interplay through the real `db_backend` harness; the real `_handle_message_inner` for the DM, group, access-denied and counter-failure paths; and the adapter override bodies executed for real — mutation-verified, a typo'd metadata key turns the suite red. A facade-delegation guard covers the wholesale-mock blindness recorded in docs/memory/learnings.md. Verified against a live instance: real Telegram webhook payloads through the real transport drive the roster 0->1->2; a group message reaches the router and is not counted; an access-denied message creates no row. Follow-up #1552 records the read-time-derivation reframe (deriving the roster from public_chat_messages) that this tactical fix deliberately defers. Fixes #1533 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ess-denied case The public-repo rule in CLAUDE.md calls for `user@example.com`-style placeholders. `a@b.com` was copied from the neighbouring access-gate test and was also inconsistent with the rest of this file, which already uses `alice@example.com`. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe
approved these changes
Jul 9, 2026
vybe
left a comment
Contributor
There was a problem hiding this comment.
Validated via /validate-pr: Fixes #1533. record_inbound() atomic upsert removes a cross-worker SELECT-then-INSERT race; regression test test_1533_client_message_count.py present. All required checks green (incl. schema-parity, pg-migrations). Security scan clean. Approving.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The agent Sharing tab's client roster showed
0messages for every external client. Investigating it turned up a wider fault than the issue describes — one dead call site causing three symptoms:message_countwas always0— the reported bug.last_activewas also stale. It was the/logintimestamp, not last activity./loginnever appeared in the roster at all.Root cause:
get_or_create_chat_linkandincrement_message_count(Telegram + WhatsApp) had zero callers outside thedatabase.pyfacade. The shared inbound path (message_router._handle_message_inner) writespublic_chat_sessions— a different table — so*_chat_linksrows were only ever created byset_*_verified_email. The read path faithfully echoed a column nobody wrote, andclient_roster_service's own docstring asserted the data existed.Changes
adapters/base.py— new default-no-oprecord_inbound_activity(message, agent_name)hook on theChannelAdapterABC, alongside the existingenrich_message/on_response_sentdefaults. Slack and VoIP inherit the no-op (Invariant Fix git pushing bug #9).adapters/message_router.py— calls it once per delivered DM at step 5c, after_enforce_access_policy(so an unauthenticated stranger who messages the bot cannot create unbounded chat-link rows) and skipped for groups (a chat link is DM-keyed; counting group traffic would list members who never DM'd the agent). Wrapped intry/except— a counter write must never block message processing.adapters/telegram_adapter.py,adapters/whatsapp_adapter.py— override the hook.db/telegram_channels.py,db/whatsapp_channels.py— newrecord_inbound(): one atomicINSERT … ON CONFLICT DO UPDATEreplacingget_or_create+increment. This removes a cross-worker SELECT-then-INSERT race (--workers 2; the oldget_or_create_chat_linkhad noIntegrityErrorcatch), halves the writes per message, and refreshes a stale display name viaCOALESCE(excluded, existing). Becauseincrement_message_countwaslast_active's only live writer, one call fixes the count and the timestamp together._row_to_chat_linkhelper only they called. Verified zero callers acrosssrc/,tests/, and the privatetrinity-enterprisesubmodule.SharingPanel.vue— theMessagesheader now states counts are not backfilled.architecture.md,telegram-integration.md(which documented the deleted methods and claimedmessage_countwas "Incremented per message"),whatsapp-integration.md, plus alearnings.mdentry.No schema change, so no migration on either track.
Test Plan
db_backendharness — roster read-back0→1→2,last_activeadvances, username backfills,/logininterplay, per-client isolation. Passes on SQLite and real PostgreSQL (on_conflict_do_updateportability is the main technical risk; SQLite alone would not have proven it)._handle_message_inner) — DM counts; group, access-denied do not; a raising hook still delivers the reply.sender_usernameand the suite goes red. Without this test, that typo ships green.database = MagicMock()stub cannot see a missing/renamed facade method (learnings.md, 2026-07-06).tests/unit: 3648 passed. Channel/router suites vs a live backend: 97 passed. The 5 failures reproduce identically on a pristineorigin/devworktree (macOS-Dockerpgroup×2, aEurope/Kievtz case, 2 live-API anon-session tests). Zero regressions.Updatepayloads POSTed to the real webhook endpoint with a valid secret token, through the real transport →parse_message→ router → hook → adapter → production SQLite:require_email=true, unverified sender →Access deniedat the router, no row created (proves the hook sits after the gate)('alice', 1, …032Z); DM Feature/gemini runtime support #2 →('alice', 2, …822Z)— one row, no duplicate,last_activeadvanced@mention) → reaches the router (START channel=-100123456,DONE) and is not countedGET /api/agents/{name}/clients→[{"identity":"@alice","message_count":2,…}]Notes
0; that is deliberate ("delivered turns, not gate-rejected attempts") and documented inrecord_inbound().public_chat_messages, which would be backfilled and cover Slack DMs) that this tactical fix deliberately defers.Fixes #1533
🤖 Generated with Claude Code