fix(channels): persist proactive messages to channel session history (#1600)#1650
Conversation
…1600) A proactive `send_message` was delivered and written nowhere. The next inbound turn builds context from `build_public_chat_context`, which only ever contained turns the message router persisted — so the agent had no record of its own outreach. It would repeat itself, contradict the message, or fail to parse the reply ("yes, sounds good" — to what?). The issue's suggested fix cannot work as written. It proposes keying off the chat link's `session_id`, describing that seam as already existing. It doesn't: `telegram_chat_links.session_id` and `whatsapp_chat_links.session_id` are **never written by any code path** — three SELECTs across the repo and no writer. `record_inbound` creates links without one. Implemented as suggested, this would have persisted nothing, silently, and looked fixed. So the session is resolved instead by identifier, via `get_or_create_public_chat_session`. The identifier MUST equal the one an inbound reply resolves to — persisting into a different session is a write that succeeds while the bug survives invisibly. To guarantee that, each `_deliver_*` derives it by driving its OWN adapter's `get_session_identifier()` with a synthetic DM, rather than re-deriving the format in the service where it would drift. (The formats differ subtly: telegram's bot_id falls back to "" in the webhook but "unknown" in the adapter — exactly the kind of mismatch that would mint a second session.) Slack is fixed too, not documented as a limitation. It has no chat-link table, but its DM key is `team:sender:channel` and `_deliver_slack` already resolves all three (workspace team_id, looked-up user, opened DM channel). Attribution follows router step 11 (#903): assistant role, agent `sender_label`, recipient `sender_email` — a proactive send targets one verified email, i.e. a single-participant DM, so `_assistant_sender_email`'s rule resolves to the recipient and sender-filtered MEM-001 keeps it in the right user's memory. Persistence hangs off `_send_message_inner`, not the shared `_deliver_*` helpers, so `send_access_grant_notification` (out of scope, #951) is excluded structurally rather than by a flag. It runs only on confirmed delivery (no phantom turn on failure) and is fail-soft: the message is already sent when it runs, so a DB error is logged loudly and never surfaces as a delivery failure. `send_group_message` (#349/#350) has the same gap but a different session model — Telegram group sessions are per-(sender, chat) with no sender for a broadcast, Slack channels are thread-scoped. Filed as #1649 rather than guessed at here. Related to #1600 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
) CI's sys.modules pollution lint flagged the bare `sys.modules.pop(...)` the fixture used to force a fresh import of the service under stubbed deps. monkeypatch.delitem(..., raising=False) does the same job and restores the real module afterwards, so the deletion can't leak into later tests — the failure mode the lint exists to prevent. Verified: lint exits 0 (204 violations vs a 240 baseline, none new); the 12 tests still pass; both proactive suites pass together under random ordering; a 473-test channels/proactive sweep is green, so the stubbing doesn't leak. Related to #1600 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
) The existing tests had a hole. `TestSessionIdentifierParity` compares two probes constructed *in the test file* — it proves the adapters are self-consistent, which was never in doubt, and never touches the probe the service builds. Demonstrated by sabotaging the service two ways, each of which silently sends the proactive turn to a DIFFERENT session (i.e. leaves the bug fully intact): - telegram metadata key typo: bot_id -> botid_TYPO, yielding "unknown:..." - slack probe dropping is_dm, taking the thread branch instead of the DM one All 12 tests passed against both. The suite would have shipped green over a non-fix. Adds TestDeliveryPathProducesTheInboundKey: drives the real `_deliver_telegram` / `_deliver_slack` with mocked channel APIs and asserts the returned `session_identifier` equals the key an inbound DM resolves to. Both sabotages now fail their test and nothing else. Also asserts a failed send carries no session key at all (nothing to persist against). Still not covered, and stated plainly in the PR rather than implied: AC-2's end-to-end round-trip (proactive send -> inbound reply -> context contains the message). Every db call here is mocked, so nothing proves add_public_chat_message and build_public_chat_context actually round-trip through a real store. Related to #1600 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Test-coverage correction (
|
…e-import (#1600) CI's base-vs-head regression diff caught this: two EXISTING tests failed under HEAD that pass on base. test_1609_proactive_rate_limits::test_limit_is_sourced_from_settings_not_the_constant test_1609_proactive_rate_limits::test_zero_limit_is_unlimited_and_skips_redis Mechanism. The fixture stubbed sys.modules and forced a re-import of services.proactive_message_service so the stubs would be bound at import time. Re-importing creates a NEW module object and rebinds the `proactive_message_service` attribute on the `services` PACKAGE to it. monkeypatch restores sys.modules — but not that package attribute. #1609 then reaches the module via `import services.proactive_message_service as pms`, which resolves through the package attribute (getattr on the package), while its `svc` fixture goes through sys.modules and gets the ORIGINAL module. So #1609 patched get_proactive_rate_limit on one object and exercised another; its patch silently did nothing and the real limit (10) answered instead. Fix: don't touch sys.modules at all. The service binds `db` and `platform_audit_service` at module level, so patch those names on the module — monkeypatch.setattr(pms, "db", fake) — which is also the pattern #1609 itself documents ("patch the name as bound in the CONSUMER module ... robust to sys.modules identity quirks"). This is strictly less machinery: no module stubbing, no re-import, no delitem, and nothing to leak. Verified: both files pass in BOTH orders and under random seeds (the failure needed 1600-then-1609 in one session, which is why local runs missed it and CI's seed matrix didn't); sys.modules lint OK; a 553-test channels/proactive sweep is green. Related to #1600 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Validated via
Findings this pass1. CI caught a real regression — my tests poisoned another suite. The base-vs-head diff flagged two EXISTING tests failing under HEAD: My fixture stubbed Fixed in 2. The tests didn't cover the fix ( Validation summary
Known gaps (stated, not hidden)
RecommendationAPPROVE once CI is green — pending the regression diff clearing on The fix itself is sound and the reasoning is documented where the next reader will hit it. But I'd weight an independent review heavily here: across three passes this PR produced a test suite that passed over a sabotaged fix and a fixture that broke a neighbouring suite. Both were found by adversarial checks and CI, not by me reading my own work. The AC-2 round-trip is the gap I'd most want closed before this is trusted in production. |
vybe
left a comment
There was a problem hiding this comment.
Validated via /validate-pr: session-key parity is asserted through the adapters' own get_session_identifier (both probe-parity and service-probe tests — the latter catch a sabotaged probe), persist-only-on-confirmed-delivery, fail-soft persistence, #903 attribution verified (sender_email=recipient for single-participant DM). Confirmed the db facade call shape matches the inbound router's (agent_name as link_id is the router's own convention, message_router.py:455). Security battery clean. Approving.
Summary
A proactive
send_message(#321) was delivered and written nowhere. The next inbound turn builds context frombuild_public_chat_context, which only ever contained turns the message router persisted — so the agent had no record of its own outreach. It would repeat itself, contradict the message, or fail to parse the reply ("yes, sounds good" — to what?).Now fixed for all three DM channels (Telegram, WhatsApp, and Slack).
The issue proposes keying off the chat link's
session_id, and states the seam "already exists for Telegram and WhatsApp". It doesn't.telegram_chat_links.session_idandwhatsapp_chat_links.session_idare never written by any code path. Across the whole repo they appear in exactly three places, allSELECTs:record_inbound(the only writer of those tables) insertstelegram_user_id+message_countand never a session. They're vestigial columns.Implemented as suggested, the fix would have persisted nothing — silently — and looked done. AC-6 ("recipient with no session yet") isn't an edge case under that design; it's every recipient.
Approach
Resolve the session by identifier via
get_or_create_public_chat_session, and make the identifier provably equal to the inbound one.That equality is the whole fix. Persisting into a session is worthless — it has to be the session the recipient's next message resolves to, or the write succeeds and the bug survives invisibly. So each
_deliver_*setsDeliveryResult.session_identifierby driving its own adapter'sget_session_identifier()with a synthetic DM, never by re-deriving the format in the service:{bot_id}:{user}:{chat}bot_id+ chat link'stelegram_user_id(a DM's chat_id IS the user id — the existing send already relies on this){binding_id}:{phone}wa_user_phone{team}:{user}:{dm_channel}team_id+ looked-up user + opened DM channelWhy not just format the string in the service? The formats have subtle fallbacks that would drift — Telegram's
bot_iddefaults to""in the webhook but"unknown"in the adapter. A mismatch mints a second session and reintroduces the bug in a place nothing checks.Slack is fixed, not documented as a limitation (AC-4 allowed either). It has no chat-link table, but
_deliver_slackalready resolves team, user, and DM channel — everything its DM key needs.AC coverage
sender_email= recipient. A proactive send targets ONE verified email — a DM, i.e. single-participant — so_assistant_sender_email's rule (Slack: scope chat session to thread, not channel #903) resolves to the recipient, keeping sender-filtered MEM-001 in the right user's memoryScope: persistence hangs off
_send_message_inner, not the shared_deliver_*helpers, sosend_access_grant_notification(out of scope, #951) is excluded structurally rather than by a flag. Fail-soft throughout: the message is already delivered when persistence runs, so a DB error is logged loudly and never surfaces as a delivery failure.send_group_message→ #1649Confirmed the same gap (0 persistence calls in both
send_telegram_group_messageandsend_agent_slack_channel_message), but it's a different problem, not a port: Telegram group sessions are per-(sender, chat) and a broadcast has no sender; Slack channels are thread-scoped on atsonly knowable after the send. That's a semantics decision — filed as #1649 with the analysis.Test Plan
pytest tests/unit/test_1600_proactive_history.py— 12 passedpytest tests/test_proactive_audit_unit.py— existing suite green (16 total)_persist_outboundcall failstest_successful_delivery_persists(the end-to-end regression). Honest note: only that 1 of 12 flips — the parity tests drive the adapters, which this PR doesn't change; they exist to catch future drift, not to prove today's fix.Related to #1600
Fixes #1600