-
Notifications
You must be signed in to change notification settings - Fork 52.3k
feat(gateway): config-driven per-chat channel_context injection #43728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
liuhao1024
wants to merge
1
commit into
NousResearch:main
Choose a base branch
from
liuhao1024:feat/channel-context-map
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+329
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,256 @@ | ||
| """Tests for gateway.channel_context_map — per-chat context injection.""" | ||
|
|
||
| import json | ||
| import time | ||
| from pathlib import Path | ||
| from unittest.mock import patch | ||
|
|
||
| import pytest | ||
|
|
||
| from gateway.platforms.base import MessageEvent, MessageType | ||
| from gateway.session import Platform, SessionSource | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # _load_channel_context_map unit tests | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| class TestLoadChannelContextMap: | ||
| """Unit tests for the module-level _load_channel_context_map() helper.""" | ||
|
|
||
| def test_no_config_returns_empty(self): | ||
| """When gateway.channel_context_map is absent, return {}.""" | ||
| from gateway.run import _load_channel_context_map | ||
|
|
||
| with patch("hermes_cli.config.load_config", return_value={}): | ||
| assert _load_channel_context_map() == {} | ||
|
|
||
| def test_empty_string_returns_empty(self): | ||
| from gateway.run import _load_channel_context_map | ||
|
|
||
| with patch( | ||
| "hermes_cli.config.load_config", | ||
| return_value={"gateway": {"channel_context_map": ""}}, | ||
| ): | ||
| assert _load_channel_context_map() == {} | ||
|
|
||
| def test_inline_dict(self): | ||
| """An inline dict in config is returned directly.""" | ||
| from gateway.run import _load_channel_context_map | ||
|
|
||
| ctx = {"telegram:123": "Session A context", "discord:456": "Session B"} | ||
| with patch( | ||
| "hermes_cli.config.load_config", | ||
| return_value={"gateway": {"channel_context_map": ctx}}, | ||
| ): | ||
| result = _load_channel_context_map() | ||
| assert result == ctx | ||
|
|
||
| def test_inline_dict_skips_falsy_values(self): | ||
| from gateway.run import _load_channel_context_map | ||
|
|
||
| ctx = {"a": "ok", "b": "", "c": None} | ||
| with patch( | ||
| "hermes_cli.config.load_config", | ||
| return_value={"gateway": {"channel_context_map": ctx}}, | ||
| ): | ||
| result = _load_channel_context_map() | ||
| assert result == {"a": "ok"} | ||
|
|
||
| def test_file_path_loads_json(self, tmp_path): | ||
| """A string config value is treated as a file path.""" | ||
| from gateway.run import _load_channel_context_map | ||
|
|
||
| map_file = tmp_path / "chat-context.json" | ||
| map_file.write_text(json.dumps({"tg:1": "context one"})) | ||
|
|
||
| with patch( | ||
| "hermes_cli.config.load_config", | ||
| return_value={"gateway": {"channel_context_map": str(map_file)}}, | ||
| ): | ||
| result = _load_channel_context_map() | ||
| assert result == {"tg:1": "context one"} | ||
|
|
||
| def test_file_path_missing_file_returns_empty(self, tmp_path): | ||
| from gateway.run import _load_channel_context_map | ||
|
|
||
| missing = tmp_path / "nonexistent.json" | ||
| with patch( | ||
| "hermes_cli.config.load_config", | ||
| return_value={"gateway": {"channel_context_map": str(missing)}}, | ||
| ): | ||
| assert _load_channel_context_map() == {} | ||
|
|
||
| def test_file_path_invalid_json_returns_empty(self, tmp_path): | ||
| from gateway.run import _load_channel_context_map | ||
|
|
||
| bad = tmp_path / "bad.json" | ||
| bad.write_text("not json {{{") | ||
| with patch( | ||
| "hermes_cli.config.load_config", | ||
| return_value={"gateway": {"channel_context_map": str(bad)}}, | ||
| ): | ||
| assert _load_channel_context_map() == {} | ||
|
|
||
| def test_file_path_non_dict_json_returns_empty(self, tmp_path): | ||
| from gateway.run import _load_channel_context_map | ||
|
|
||
| arr = tmp_path / "arr.json" | ||
| arr.write_text(json.dumps(["a", "b"])) | ||
| with patch( | ||
| "hermes_cli.config.load_config", | ||
| return_value={"gateway": {"channel_context_map": str(arr)}}, | ||
| ): | ||
| assert _load_channel_context_map() == {} | ||
|
|
||
| def test_mtime_cache_reuses_unchanged_file(self, tmp_path): | ||
| """Second call within same mtime returns cached result.""" | ||
| import os | ||
| import gateway.run as gr | ||
|
|
||
| map_file = tmp_path / "ctx.json" | ||
| map_file.write_text(json.dumps({"k": "v1"})) | ||
|
|
||
| with patch( | ||
| "hermes_cli.config.load_config", | ||
| return_value={"gateway": {"channel_context_map": str(map_file)}}, | ||
| ): | ||
| r1 = gr._load_channel_context_map() | ||
| # Rewrite content but preserve the original mtime exactly | ||
| orig_stat = map_file.stat() | ||
| map_file.write_text(json.dumps({"k": "v2"})) | ||
| os.utime(map_file, (orig_stat.st_atime, orig_stat.st_mtime)) | ||
| r2 = gr._load_channel_context_map() | ||
| # Same mtime → cached value | ||
| assert r2 == {"k": "v1"} | ||
|
|
||
| def test_mtime_cache_refreshes_on_change(self, tmp_path): | ||
| """File change with new mtime triggers reload.""" | ||
| import gateway.run as gr | ||
|
|
||
| map_file = tmp_path / "ctx.json" | ||
| map_file.write_text(json.dumps({"k": "v1"})) | ||
|
|
||
| with patch( | ||
| "hermes_cli.config.load_config", | ||
| return_value={"gateway": {"channel_context_map": str(map_file)}}, | ||
| ): | ||
| r1 = gr._load_channel_context_map() | ||
| assert r1 == {"k": "v1"} | ||
|
|
||
| # Force mtime change | ||
| time.sleep(0.05) | ||
| map_file.write_text(json.dumps({"k": "v2"})) | ||
| r2 = gr._load_channel_context_map() | ||
| assert r2 == {"k": "v2"} | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Integration: _prepare_inbound_message_text with channel_context_map | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| class TestChannelContextMapInjection: | ||
| """Integration tests: config map context is injected into inbound messages.""" | ||
|
|
||
| @pytest.fixture() | ||
| def runner(self): | ||
| from gateway.config import GatewayConfig | ||
| from gateway.run import GatewayRunner | ||
|
|
||
| r = GatewayRunner.__new__(GatewayRunner) | ||
| r.config = GatewayConfig(group_sessions_per_user=False) | ||
| r.adapters = {} | ||
| r._model = "test-model" | ||
| r._base_url = "" | ||
| r._has_setup_skill = lambda: False | ||
| return r | ||
|
|
||
| @pytest.fixture() | ||
| def source(self): | ||
| """Group chat source — sender prefix is applied for shared sessions.""" | ||
| return SessionSource( | ||
| platform=Platform.TELEGRAM, | ||
| chat_id="tg:12345", | ||
| chat_type="group", | ||
| user_name="Alice", | ||
| ) | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_config_context_injected(self, runner, source): | ||
| """Config map context is prepended to the message.""" | ||
| event = MessageEvent(text="hello", source=source) | ||
| with patch( | ||
| "gateway.run._load_channel_context_map", | ||
| return_value={"tg:12345": "This is dev-session A."}, | ||
| ): | ||
| result = await runner._prepare_inbound_message_text( | ||
| event=event, source=source, history=[], | ||
| ) | ||
| assert "This is dev-session A." in result | ||
| assert "[New message]" in result | ||
| assert "[Alice] hello" in result | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_adapter_context_takes_precedence(self, runner, source): | ||
| """When adapter already set channel_context, config context is appended.""" | ||
| event = MessageEvent( | ||
| text="hello", | ||
| source=source, | ||
| channel_context="[Adapter context]", | ||
| ) | ||
| with patch( | ||
| "gateway.run._load_channel_context_map", | ||
| return_value={"tg:12345": "Config context."}, | ||
| ): | ||
| result = await runner._prepare_inbound_message_text( | ||
| event=event, source=source, history=[], | ||
| ) | ||
| # Adapter context comes first | ||
| assert result.startswith("[Adapter context]") | ||
| assert "Config context." in result | ||
| assert "[New message]" in result | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_no_config_map_no_injection(self, runner, source): | ||
| """When config map is empty, no extra context is injected.""" | ||
| event = MessageEvent(text="hello", source=source) | ||
| with patch( | ||
| "gateway.run._load_channel_context_map", | ||
| return_value={}, | ||
| ): | ||
| result = await runner._prepare_inbound_message_text( | ||
| event=event, source=source, history=[], | ||
| ) | ||
| assert "[New message]" not in result | ||
| assert result == "[Alice] hello" | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_chat_id_not_in_map_no_injection(self, runner, source): | ||
| """When chat_id is not in the map, no extra context is injected.""" | ||
| event = MessageEvent(text="hello", source=source) | ||
| with patch( | ||
| "gateway.run._load_channel_context_map", | ||
| return_value={"other:chat": "Some context."}, | ||
| ): | ||
| result = await runner._prepare_inbound_message_text( | ||
| event=event, source=source, history=[], | ||
| ) | ||
| assert "[New message]" not in result | ||
| assert result == "[Alice] hello" | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_config_context_only_no_adapter(self, runner, source): | ||
| """Config context without adapter context works correctly.""" | ||
| event = MessageEvent(text="hi", source=source) | ||
| with patch( | ||
| "gateway.run._load_channel_context_map", | ||
| return_value={"tg:12345": "Bound to workspace X."}, | ||
| ): | ||
| result = await runner._prepare_inbound_message_text( | ||
| event=event, source=source, history=[], | ||
| ) | ||
| assert result.startswith("Bound to workspace X.") | ||
| assert "[New message]" in result | ||
| assert "[Alice] hi" in result |
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This lookup uses the raw
source.chat_id, but the documented examples usetelegram:<id>anddiscord:<id>.BasePlatformAdapter.build_source()stores the raw id separately fromsource.platform, so normal events will not match those examples. Please either form a platform-qualified lookup key here or revise the key contract and tests to use raw ids.