diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 65684ced454a8..5f4b1e58ae0f3 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -5428,6 +5428,14 @@ def _window_row(idx: int, msg: Dict[str, Any]): _strip_persistence_markers(compressed) self._last_compression_made_progress = True + # Reclaim memory from compressed-away message dicts. Python's arena + # allocator keeps pages in the process heap even after objects are freed + # from the list; without an explicit collect, RSS grows unbounded over + # long sessions. (#70684) + import gc + + gc.collect() + return compressed diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index e86ba5593ecc7..966d62d9bc8b3 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -191,6 +191,34 @@ describe('toChatMessages', () => { 'background agent work finished' ]) }) + + it('handles async_delegation_complete with string display_metadata from database', () => { + const [message] = toChatMessages([ + { + role: 'assistant', + content: 'done', + display_kind: 'async_delegation_complete', + display_metadata: '{"delegation_id":"deleg_abc","task_count":3,"completed_count":3}', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('3 background agents finished') + }) + + it('handles async_delegation_complete with object display_metadata', () => { + const [message] = toChatMessages([ + { + role: 'assistant', + content: 'done', + display_kind: 'async_delegation_complete', + display_metadata: { delegation_id: 'deleg_abc', task_count: 1 }, + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('1 background agent finished') + }) }) describe('renderMediaTags', () => { diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 8541c0bb80988..a0a54281a79d6 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -317,9 +317,14 @@ function timelineDisplayContent(message: SessionMessage, content: string): strin } if (message.display_kind === 'async_delegation_complete') { + // display_metadata may arrive as a JSON string from the database + const meta = + typeof message.display_metadata === 'string' + ? (() => { try { return JSON.parse(message.display_metadata) } catch { return undefined } })() + : message.display_metadata const count = - message.display_metadata && 'task_count' in message.display_metadata - ? message.display_metadata.task_count + meta && typeof meta === 'object' && 'task_count' in meta + ? meta.task_count : undefined return count === undefined diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index f2a5303abe947..d53858ffc10ab 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -490,6 +490,10 @@ def _build_server_config( cfg["url"] = t.url if entry.auth.type == "oauth": cfg["auth"] = "oauth" + elif entry.auth.type == "api_key": + from hermes_cli.mcp_config import _bearer_auth_headers + + cfg["headers"] = _bearer_auth_headers(entry.name) return cfg diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index 0c440f4909bc8..0eba845763f8b 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -216,6 +216,21 @@ def test_transport_env_absent_leaves_config_without_env_key(self, catalog_dir): cfg = _build_server_config(_entry("demo"), None) assert "env" not in cfg + def test_http_api_key_builds_bearer_headers_template(self, catalog_dir): + body = _basic_manifest( + transport={"type": "http", "url": "https://mcp.example.com/sse"}, + auth={ + "type": "api_key", + "env": [{"name": "MCP_DEMO_API_KEY", "prompt": "key", "secret": True}], + }, + ) + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import _build_server_config + + cfg = _build_server_config(_entry("demo"), None) + assert cfg["url"] == "https://mcp.example.com/sse" + assert cfg["headers"] == {"Authorization": "Bearer ${MCP_DEMO_API_KEY}"} + def test_transport_env_bad_shape_rejected(self, catalog_dir): body = _basic_manifest() body["transport"]["env"] = ["DISABLE_TELEMETRY=true"] # list, not mapping @@ -334,6 +349,29 @@ def test_install_http_oauth_writes_auth_marker(self, catalog_dir): assert server["url"] == "https://mcp.example.com/sse" assert server["auth"] == "oauth" + def test_install_http_api_key_writes_bearer_headers(self, catalog_dir, monkeypatch): + body = _basic_manifest( + transport={"type": "http", "url": "https://mcp.example.com/sse"}, + auth={ + "type": "api_key", + "env": [{"name": "MCP_DEMO_API_KEY", "prompt": "key", "secret": True}], + }, + ) + _write_manifest(catalog_dir, "demo", body) + + from hermes_cli import mcp_catalog + + monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "secret-val") + + from hermes_cli.mcp_catalog import install_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + + server = load_config()["mcp_servers"]["demo"] + assert server["url"] == "https://mcp.example.com/sse" + assert server["headers"] == {"Authorization": "Bearer secret-val"} + def test_install_required_env_missing_raises(self, catalog_dir, monkeypatch): body = _basic_manifest( auth={