fix(channels): persist proactive group messages to channel session history (#1649)#1651
Conversation
…story (#1649) A proactive group/channel broadcast (send_group_message, #349/#350) was delivered and written nowhere, so the agent had no record of its own outreach. The group counterpart of #1600. The two channels do NOT come out equivalent, and the PR says so rather than implying parity: Slack — a real recall fix. Channel sessions are thread-scoped (team:channel:thread, #903) and an in-thread reply carries thread_ts = the PARENT's ts. So a top-level post filed at its OWN ts lands in exactly the session its replies resolve to; a threaded send uses that thread. Verified against the adapter: both sides derive T1:C1:1720000000.111222. Telegram — bookkeeping only, deliberately. Group sessions are keyed per (sender, chat) — get_session_identifier has no group branch — and a broadcast has no human sender, so it is filed under a synthetic agent-sender key ({bot_id}:{agent}:{chat_id}) that nothing else writes to. The message is recorded, auditable and attributable, but NO participant's session contains it, so the agent still will not recall its broadcast when someone replies. Real recall needs a per-chat group session, which changes behaviour for every existing inbound group conversation — a separate decision. A test pins the non-match so it can't be mistaken for a bug, and fails if the adapter later grows a group branch. Capturing Slack's ts required slack_service.send_message_detailed() -> (ok, error, ts). send_message() keeps its (ok, error) contract and delegates — ~7 call sites unpack the 2-tuple. Key derivation lives in named helpers that drive each adapter's own get_session_identifier(), so the format can't drift from the router (the #1600 trap). Attribution is #903 shared-thread: assistant role, agent sender_label, and sender_email=None — the DM case (#1600) stamps the recipient's email, but a broadcast must never be folded into one participant's MEM-001 memory. Persisted on confirmed delivery only (no phantom turn on failure) and fail-soft (the message is already sent). Tests drive the real endpoints, not just the helpers — the #1600 lesson. Deleting the Slack router's persistence call outright left all 9 helper-only tests green; the router tests fail on that, on a team_id/channel_id swap, and on the telegram synthetic-key sabotage. Related to #1649 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Validated via
Summary
What I attacked, and what it caughtThe #1600 lesson was that a green suite can sit on top of a non-fix. So I sabotaged this one before trusting it:
The first row is the point: with helper-only coverage, removing the fix outright left everything green. That's why The asymmetry a reviewer should focus onSlack is a genuine recall fix — verified against the adapter, both sides derive Telegram is bookkeeping only. If a reviewer thinks recall matters more than the migration cost, the per-chat group branch is the alternative — and that's the one call worth re-litigating here. Known gaps
RecommendationAPPROVE (pending independent review). Mechanically clean and CI-green, with the one design decision (Telegram bookkeeping vs recall) surfaced rather than buried. @vybe — the Telegram session-model trade-off is the part I'd most like a second opinion on; everything else is mechanical. |
vybe
left a comment
There was a problem hiding this comment.
Validated via /validate-pr: the Slack recall fix is asserted end-to-end at router level (posted-ts session key = the key an in-thread reply resolves to), the Telegram bookkeeping-only trade-off is pinned by a test that forces a re-decision if the adapter grows a group branch, send_message keeps its 2-tuple contract for existing callers, #903 sender_email=None attribution verified. Resolved the registry.json append conflict with #1650 (both entries kept, JSON validated). Security battery clean. Approving.
Summary
A proactive group/channel broadcast (
send_group_message, #349/#350) was delivered and written nowhere, so the agent had no record of its own outreach. This is the group counterpart of #1600 (DMs).The two channels do not come out equivalent, and that's the most important thing to know before reviewing.
team:channel:thread, #903) and an in-thread reply carriesthread_ts= the parent's ts. A top-level post filed at its own ts lands in exactly the session its replies resolve to. Verified against the adapter: both sides deriveT1:C1:1720000000.111222get_session_identifierhas no group branch — and a broadcast has no human sender. It's filed under a synthetic agent-sender key ({bot_id}:{agent}:{chat_id}) that nothing else writes toThe Telegram limitation, stated plainly
The message becomes recorded, auditable and attributable — but the agent still will NOT recall it. No participant's inbound session contains it, so when someone replies in the group, the broadcast isn't in context. Verified:
bot-7:atlas:-100999(ours) vsbot-7:55555:-100999(a participant's).Real recall needs a per-chat group session, which changes behaviour for every existing inbound group conversation — a migration and a separate decision, not something to smuggle into a bug fix.
test_telegram_key_deliberately_does_not_match_a_participantpins the non-match so it can't be mistaken for a bug, and fails if the adapter later grows a group branch, forcing a re-decision.Changes
services/channel_history.py(new) — shared persistence + key derivation. Lives in a service, not the routers (Invariant Fix: Add missing Docker labels to system agent container #1), and both endpoints need identical semantics. Key derivation drives each adapter's ownget_session_identifier()rather than re-implementing the format — the bug: proactive messages (send_message) never persisted to channel session history — agent unaware of its own outbound messages #1600 drift trap.services/slack_service.py—send_message_detailed()→(ok, error, ts).send_message()keeps its(ok, error)contract and delegates; ~7 call sites unpack the 2-tuple. Without the ts there's no way to file a top-level post in the session its replies use.routers/slack.py,routers/telegram.py— persist on confirmed delivery.Attribution (#903):
assistantrole, agentsender_label,sender_email=None. This is the deliberate difference from #1600, which stamps the recipient's email: a DM is single-participant, a broadcast is a shared thread and must never be folded into one participant's MEM-001 memory.Safety: persisted only on confirmed delivery (a failed send writes no phantom turn), and fail-soft — the message is already delivered when persistence runs, so a DB error is logged loudly, never surfaced as a send failure.
Test plan — and how I know the tests are worth anything
pytest tests/unit/test_1649_group_message_history.py— 13 passedThe tests drive the real endpoints, not just the helpers — the lesson from #1600, where a probe-vs-probe suite passed over a sabotaged fix. I checked the same way here, and it mattered: with helper-only tests, deleting the Slack router's persistence call outright left all 9 green. The router tests catch:
team_id/channel_idin the key callchat_idinstead of the agentKnown gaps
persist → build_public_chat_context contains it): everydbcall is mocked. Same gap as bug: proactive messages (send_message) never persisted to channel session history — agent unaware of its own outbound messages #1600; worth closing for both in one pass with the sqlite harness.Relationship to #1600
Independent: different layer (routers vs
proactive_message_service), different session model, no shared code path today. This branches fromdev, so it doesn't contain #1600. Once both land, making #1600's_persist_outbounddelegate tochannel_historywould remove the near-duplicate — worth a small follow-up, deliberately not done here to keep the PRs reviewable apart.Related to #1649
Fixes #1649