From 2a04137322017193e206a105bce29938f4ebf58c Mon Sep 17 00:00:00 2001 From: r266-tech Date: Mon, 22 Jun 2026 09:30:49 +0800 Subject: [PATCH] fix(gateway): preserve platform + gateway_session_key on /compress temp agent Manual /compress built a temporary AIAgent without the originating platform / stable gateway session key, so an external context engine ingested the retained transcript tail as source=cli during /compress and again as the real platform on resume (duplicate cli,telegram rows). Pass platform=_platform_config_key(source.platform) + the in-scope gateway_session_key, mirroring the normal gateway turn. Assigned into runtime_kwargs (single-valued, authoritative) so they neither collide into a duplicate-kwarg TypeError nor lose to a stale resolver value. Fixes #50422. --- gateway/slash_commands.py | 26 ++++++++++ tests/gateway/test_compress_command.py | 70 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 7a62889d7..22c11e59e 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3056,6 +3056,17 @@ class GatewaySlashCommandsMixin: from agent.model_metadata import estimate_request_tokens_rough session_key = self._session_key_for_source(source) + # Preserve the same platform + stable gateway session identity that a + # normal gateway turn passes (gateway/run.py main turn), so external + # context engines bind this temporary compression agent to the + # original platform conversation instead of falling back to an + # unbound/default "cli" host source — see #50422. _platform_config_key + # maps LOCAL->"cli" exactly like the live turn, avoiding a new + # "local" vs "cli" mismatch. + from gateway.run import _platform_config_key + platform_key = ( + _platform_config_key(source.platform) if source.platform else None + ) model, runtime_kwargs = self._resolve_session_agent_runtime( source=source, session_key=session_key, @@ -3082,6 +3093,21 @@ class GatewaySlashCommandsMixin: partial = False head = msgs + # Bind the temporary compression agent to the originating source's + # platform + stable gateway session key. These are *authoritative* + # identity invariants (derived from `source`), so assign them into + # runtime_kwargs directly rather than via setdefault: a value already + # present there from the resolver would be a placeholder/stale + # identity and must not win. Assigning (vs passing a second explicit + # kwarg) also keeps each key single-valued, avoiding a "got multiple + # values for keyword argument" TypeError. platform is only set when + # known: for a source without platform metadata we leave it unset so + # AIAgent's default (platform=None -> source "cli") applies, exactly + # the prior behavior. _resolve_session_agent_runtime does not set + # either key today, so in practice this just adds them. + if platform_key is not None: + runtime_kwargs["platform"] = platform_key + runtime_kwargs["gateway_session_key"] = session_key tmp_agent = AIAgent( **runtime_kwargs, model=model, diff --git a/tests/gateway/test_compress_command.py b/tests/gateway/test_compress_command.py index 21029c7b7..88e53e212 100644 --- a/tests/gateway/test_compress_command.py +++ b/tests/gateway/test_compress_command.py @@ -413,3 +413,73 @@ async def test_compress_command_in_place_write_failure_reports_error(): runner.session_store._save.assert_not_called() agent_instance.shutdown_memory_provider.assert_called_once() agent_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_compress_command_preserves_platform_and_gateway_session_key(): + """The temporary compression agent must carry the originating source's + platform and stable gateway session key, matching a normal gateway turn. + Without them ``_session_source_for_agent`` falls back to a default "cli" + host source, so an external context engine misattributes the retained + transcript tail and later duplicates it on resume (#50422).""" + history = _make_history() + runner = _make_runner(history) + agent_instance = MagicMock() + agent_instance.shutdown_memory_provider = MagicMock() + agent_instance.close = MagicMock() + agent_instance._cached_system_prompt = "" + agent_instance.tools = None + agent_instance.context_compressor.has_content_to_compress.return_value = True + agent_instance.session_id = "sess-1" + agent_instance._compress_context.return_value = (list(history), "") + + with ( + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), + patch("gateway.run._resolve_gateway_model", return_value="test-model"), + patch("run_agent.AIAgent", return_value=agent_instance) as mock_agent, + patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100), + ): + await runner._handle_compress_command(_make_event()) + + assert mock_agent.call_count == 1 + _, kwargs = mock_agent.call_args + # Platform preserved as the live turn's config key (TELEGRAM -> "telegram"), + # not the unbound "cli"/"local" fallback. + assert kwargs.get("platform") == "telegram" + # Stable gateway session key preserved, identical to a normal gateway turn. + assert kwargs.get("gateway_session_key") == runner._session_key_for_source(_make_source()) + assert kwargs["gateway_session_key"] + + +@pytest.mark.asyncio +async def test_compress_command_overrides_stale_resolver_identity(): + """If the resolver already supplies platform/gateway_session_key, the + construction must (a) not raise "got multiple values for keyword argument", + and (b) let the originating-source identity win — a stale/placeholder + resolver value must not defeat the attribution fix.""" + history = _make_history() + runner = _make_runner(history) + agent_instance = MagicMock() + agent_instance.shutdown_memory_provider = MagicMock() + agent_instance.close = MagicMock() + agent_instance._cached_system_prompt = "" + agent_instance.tools = None + agent_instance.context_compressor.has_content_to_compress.return_value = True + agent_instance.session_id = "sess-1" + agent_instance._compress_context.return_value = (list(history), "") + + # Resolver injects a WRONG platform and a stale session key. + runtime = {"api_key": "test-key", "platform": "discord", "gateway_session_key": "stale-key"} + with ( + patch("gateway.run._resolve_runtime_agent_kwargs", return_value=runtime), + patch("gateway.run._resolve_gateway_model", return_value="test-model"), + patch("run_agent.AIAgent", return_value=agent_instance) as mock_agent, + patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100), + ): + await runner._handle_compress_command(_make_event()) # must not raise + + assert mock_agent.call_count == 1 + _, kwargs = mock_agent.call_args + # Source-derived identity overrides the stale resolver values, passed once. + assert kwargs["platform"] == "telegram" + assert kwargs["gateway_session_key"] == runner._session_key_for_source(_make_source())