From e027f43f70736370efdcaa6733ef722cabcc8107 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:47:33 -0700 Subject: [PATCH] fix(gateway): key Slack capability gate into the prompt pin; defer positive note to tool schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the #68627 cherry-pick (cluster C15 — Slack platform capability-note accuracy; earliest report/fix: #6545 by @daikeren): 1. Session/prompt stability: the pinned session-context render (_pinned_session_context_prompt) is keyed by _ephemeral_change_key, whose contract requires every rendered input to appear in the key. The new _slack_tools_loaded() gate reads config + the live MCP registration map, so its state is now hashed into the key exactly like the existing Discord gate — a gate flip re-renders ONCE (a legitimate bust); within a session the note stays byte-stable for the life of the conversation (A/B: the new parity test fails with this key change reverted, passes with it). 2. Derived, non-overpromising positive note: rather than hardcoding a capability list that can drift stale again (the original bug class), the tools-present note tells the agent to consult the actual loaded Slack tool schemas for supported operations — the schemas ARE the source of truth, so the note cannot overclaim ops a given Slack toolset/MCP server doesn't expose (e.g. a read-only history server). 3. Tests: parity test proving a gate flip changes both render and key; byte-stability test proving three consecutive turns in one Slack session return the identical pinned object (sha256-equal); autouse fixture pins the new gate so key<->render parity is env-independent. --- gateway/run.py | 12 +++++++ gateway/session.py | 8 +++-- tests/gateway/test_prompt_tail_freeze.py | 42 +++++++++++++++++++++++- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 5e08eebe8064..0f74680bf079 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -18641,6 +18641,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "1" if src.message_id else "0", ) + # Slack renders a capability-aware platform note gated on + # _slack_tools_loaded() — the gate state must appear in the key + # (same parity contract as the Discord gate above) so a config / + # MCP-registration flip re-renders once instead of serving a + # stale pinned note for the rest of the session. + slack_tools = "" + if src.platform == Platform.SLACK: + from gateway.session import _slack_tools_loaded + + slack_tools = "1" if _slack_tools_loaded() else "0" + try: from hermes_constants import display_hermes_home @@ -18661,6 +18672,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew bool(context.shared_multi_user_session), discord_ids, discord_tools, + slack_tools, tuple(p.value for p in context.connected_platforms), tuple( ( diff --git a/gateway/session.py b/gateway/session.py index c258a3ac1183..ed2ffaea10d3 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -572,9 +572,11 @@ def build_session_context_prompt( lines.append("") lines.append( "**Platform notes:** You are running inside Slack and have access " - "to Slack-specific tools. You CAN search channel history, post " - "messages, react to messages, and perform other Slack operations " - "via the available Slack toolset." + "to Slack-specific tools this session. Consult the available Slack " + "tool schemas for the exact operations supported (e.g. channel " + "history and thread lookups, posting, reactions) — use those tools " + "for Slack-specific requests, and do not promise Slack actions " + "beyond what the loaded tools actually expose." ) else: lines.append("") diff --git a/tests/gateway/test_prompt_tail_freeze.py b/tests/gateway/test_prompt_tail_freeze.py index 285c1ccd6bcd..40d3ee800926 100644 --- a/tests/gateway/test_prompt_tail_freeze.py +++ b/tests/gateway/test_prompt_tail_freeze.py @@ -99,9 +99,10 @@ def _make_context( @pytest.fixture(autouse=True) def _stable_discord_tools(monkeypatch): - """Pin the config/env-dependent renderer gate so key<->render parity is + """Pin the config/env-dependent renderer gates so key<->render parity is evaluated on the same footing in every environment.""" monkeypatch.setattr("gateway.session._discord_tools_loaded", lambda: True) + monkeypatch.setattr("gateway.session._slack_tools_loaded", lambda: False) def _key(runner, context, redact_pii=False): @@ -192,6 +193,45 @@ class TestEphemeralChangeKeyParity: assert _render(ctx) != render_on assert _key(runner, ctx) != key_on + def test_slack_tools_gate_flip_changes_key(self, monkeypatch): + """The Slack capability note is gated on _slack_tools_loaded(); the + gate state must be part of the change key (same parity contract as + the Discord gate) or a config/MCP flip would serve a stale pinned + note forever.""" + runner = _make_runner() + ctx = _make_context( + platform=Platform.SLACK, + chat_id="C123", + thread_id=None, + parent_chat_id=None, + guild_id=None, + ) + render_off, key_off = _render(ctx), _key(runner, ctx) + monkeypatch.setattr("gateway.session._slack_tools_loaded", lambda: True) + assert _render(ctx) != render_off + assert _key(runner, ctx) != key_off + + def test_slack_note_byte_stable_across_turns_in_one_session(self): + """Within one session (gate state constant), the Slack platform note + must be byte-stable turn over turn — the pin returns the identical + object, so the composed system prompt cannot drift mid-conversation.""" + runner = _make_runner() + + def _slack_ctx(): + return _make_context( + platform=Platform.SLACK, + chat_id="C123", + thread_id=None, + parent_chat_id=None, + guild_id=None, + ) + + t1 = runner._pinned_session_context_prompt(_slack_ctx(), False, "sk-slack") # noqa: SLF001 + t2 = runner._pinned_session_context_prompt(_slack_ctx(), False, "sk-slack") # noqa: SLF001 + t3 = runner._pinned_session_context_prompt(_slack_ctx(), False, "sk-slack") # noqa: SLF001 + assert t2 is t1 and t3 is t1 + assert hashlib.sha256(t1.encode()).hexdigest() == hashlib.sha256(t3.encode()).hexdigest() + def test_message_id_value_change_is_not_a_bust(self): """Only message-id PRESENCE renders (the id itself rides the user message) — a new id every turn must not re-render."""