"""Client projections must not expose model-only compaction scaffolding.""" from __future__ import annotations from aiohttp.test_utils import TestClient, TestServer import pytest from agent.compaction_display import project_compaction_message_for_display from agent.context_compressor import ( COMPRESSED_SUMMARY_METADATA_KEY, HISTORICAL_TASK_HEADING, SUMMARY_PREFIX, _MERGED_PRIOR_CONTEXT_HEADER, _MERGED_SUMMARY_DELIMITER, _SUMMARY_END_MARKER, ) from gateway.config import PlatformConfig from gateway.platforms.api_server import ( APIServerAdapter, _is_compressed_summary_message, ) from hermes_state import SessionDB STANDALONE_SUMMARY = ( f"{SUMMARY_PREFIX}\n\n" f"{HISTORICAL_TASK_HEADING}\nold work\n\n" f"{_SUMMARY_END_MARKER}" ) MERGED_CARRIER = ( f"{_MERGED_PRIOR_CONTEXT_HEADER}\n" "Refactor complete.\n\n" f"{_MERGED_SUMMARY_DELIMITER}\n\n" f"{STANDALONE_SUMMARY}" ) REAL_USER = "test the browser controller again" def _row(role: str, content, **extra) -> dict: row = {"id": 1, "session_id": "s1", "role": role, "content": content} row.update(extra) return row @pytest.fixture def session_db(tmp_path): db = SessionDB(tmp_path / "state.db") try: yield db finally: close = getattr(db, "close", None) if callable(close): close() @pytest.fixture def adapter(session_db): adapter = APIServerAdapter(PlatformConfig(enabled=True)) adapter._session_db = session_db return adapter def _messages_app(adapter: APIServerAdapter): from aiohttp import web app = web.Application() app.router.add_get( "/api/sessions/{session_id}/messages", adapter._handle_session_messages, ) return app class TestMessageProjection: def test_standalone_summary_is_hidden_without_scaffolding(self): projected = APIServerAdapter._message_response( _row( "user", STANDALONE_SUMMARY, tool_calls=[{"id": "stale"}], reasoning="internal compression reasoning", reasoning_content="internal compression reasoning", reasoning_details=[{"type": "reasoning.summary", "summary": "internal"}], codex_reasoning_items=[{"type": "reasoning", "id": "internal"}], codex_message_items=[{"type": "message", "id": "internal"}], ) ) assert projected["content"] == "" assert projected["display_kind"] == "hidden" assert "tool_calls" not in projected assert "finish_reason" not in projected assert "reasoning" not in projected assert "reasoning_content" not in projected assert "reasoning_details" not in projected assert "codex_reasoning_items" not in projected assert "codex_message_items" not in projected def test_merged_carrier_preserves_only_real_prior_content(self): projected = APIServerAdapter._message_response( _row( "assistant", MERGED_CARRIER, tool_calls=[{"id": "prior-call"}], finish_reason="tool_calls", ) ) assert projected["content"] == "Refactor complete." assert "tool_calls" not in projected assert "finish_reason" not in projected assert "PRIOR CONTEXT" not in projected["content"] assert "CONTEXT COMPACTION" not in projected["content"] def test_merged_content_array_preserves_blocks_before_summary(self): projected = APIServerAdapter._message_response( _row( "user", [ { "type": "text", "text": f"{_MERGED_PRIOR_CONTEXT_HEADER}\n{REAL_USER}", }, { "type": "text", "text": f"{_MERGED_SUMMARY_DELIMITER}\n{STANDALONE_SUMMARY}", }, ], ) ) assert projected["content"] == [{"type": "text", "text": REAL_USER}] def test_real_message_that_mentions_marker_text_is_untouched(self): message = _row( "user", "please explain the string [CONTEXT COMPACTION] in this bug report", tool_calls=[{"id": "real"}], reasoning="real provider payload", ) projected = project_compaction_message_for_display(message) assert projected == message assert projected is not message def test_unrelated_hidden_message_is_not_reclassified_as_compaction(self): message = _row("assistant", "ordinary hidden control row", display_kind="hidden") assert _is_compressed_summary_message(message) is False class TestSummaryRecognizer: @pytest.mark.parametrize( "message", [ _row("user", STANDALONE_SUMMARY), _row("assistant", MERGED_CARRIER), _row("assistant", "metadata-only", **{COMPRESSED_SUMMARY_METADATA_KEY: True}), ], ) def test_recognizes_all_compaction_carrier_shapes(self, message): assert _is_compressed_summary_message(message) is True def test_ignores_real_message(self): assert _is_compressed_summary_message(_row("user", REAL_USER)) is False class TestTurnTranscriptProjection: def test_run_completed_strips_scaffolding_but_keeps_real_carrier_content(self): result = { "messages": [ {"role": "user", "content": REAL_USER}, {"role": "assistant", "content": "checking the controller"}, _row("user", STANDALONE_SUMMARY), _row("assistant", MERGED_CARRIER), {"role": "assistant", "content": "the controller is ready"}, ], "final_response": "the controller is ready", } turn = APIServerAdapter._turn_transcript_messages( [{"role": "user", "content": REAL_USER}], REAL_USER, result, ) assert [message.get("content") for message in turn] == [ "checking the controller", "Refactor complete.", "the controller is ready", ] class TestMessagesEndpointProjection: @pytest.mark.asyncio async def test_suppressed_delegation_row_keeps_evidence_but_not_presentation(self, adapter, session_db, tmp_path, monkeypatch): from gateway.wake import persist_delegation_delivery monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text("display: {suppress_warning_notifications: true}") sid = session_db.create_session("diagnostic-projection", "api_server") event = {"delegation_id": "early-failure", "task_failure_notice": True, "results": [{"task_index": 0, "status": "failed"}]} await persist_delegation_delivery(adapter, text="recorded worker failure", session_id=sid, evt=event) await persist_delegation_delivery(adapter, text="recorded worker failure", session_id=sid, evt=event) stored = session_db.get_messages(sid) assert len(stored) == 1 and stored[0]["content"] == "recorded worker failure" async with TestClient(TestServer(_messages_app(adapter))) as client: response = await client.get(f"/api/sessions/{sid}/messages") assert response.status == 200 payload = await response.json() assert len(payload["data"]) == 1 assert payload["data"][0]["content"] == "" assert payload["data"][0]["display_kind"] == "hidden" @pytest.mark.asyncio async def test_messages_endpoint_never_serves_compaction_scaffolding( self, adapter, session_db, ): session_id = session_db.create_session("projection-session", "api_server") session_db.replace_messages( session_id, [ _row("user", STANDALONE_SUMMARY), _row("assistant", MERGED_CARRIER), _row("user", REAL_USER), ], ) async with TestClient(TestServer(_messages_app(adapter))) as client: response = await client.get(f"/api/sessions/{session_id}/messages") assert response.status == 200 payload = await response.json() messages = payload["data"] assert len(messages) == 3 assert messages[0]["content"] == "" assert messages[0]["display_kind"] == "hidden" assert messages[1]["content"] == "Refactor complete." assert messages[2]["content"] == REAL_USER rendered = " ".join(str(message.get("content") or "") for message in messages) assert "PRIOR CONTEXT" not in rendered assert "CONTEXT COMPACTION" not in rendered @pytest.mark.asyncio async def test_messages_endpoint_honours_include_compacted(self, adapter, session_db, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) sid = session_db.create_session("compacted-history", "api_server") for i in range(1, 4): session_db.append_message(sid, "user", f"question {i}") session_db.append_message(sid, "assistant", f"answer {i}") tail = [{"role": "user", "content": "question 3"}, {"role": "assistant", "content": "answer 3"}] session_db.archive_and_compact( sid, [_row("user", STANDALONE_SUMMARY), *tail], watermark=session_db.get_active_message_watermark(sid), tail_count=len(tail)) async def contents(query: str) -> list: async with TestClient(TestServer(_messages_app(adapter))) as client: response = await client.get(f"/api/sessions/{sid}/messages{query}") assert response.status == 200 return [m.get("content") for m in (await response.json())["data"]] live = await contents("") assert "question 1" not in live and "answer 3" in live for query in ("?include_compacted=false", "?include_compacted=0"): assert await contents(query) == live full = await contents("?include_compacted=true") for text in ("question 1", "answer 1", "question 2", "answer 2", "question 3", "answer 3"): assert full.count(text) == 1, (text, full) assert full.index("question 1") < full.index("answer 3") @pytest.mark.asyncio async def test_include_compacted_keeps_compression_ancestors(self, adapter, session_db, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) session_db.create_session("rotated-parent", "api_server") session_db.append_message("rotated-parent", "user", "parent ask") session_db.append_message("rotated-parent", "assistant", "parent answer") session_db.end_session("rotated-parent", "compression") child = session_db.create_session("rotated-child", "api_server", parent_session_id="rotated-parent") for i in range(1, 3): session_db.append_message(child, "user", f"child question {i}") session_db.append_message(child, "assistant", f"child answer {i}") tail = [{"role": "user", "content": "child question 2"}, {"role": "assistant", "content": "child answer 2"}] session_db.archive_and_compact( child, [_row("user", STANDALONE_SUMMARY), *tail], watermark=session_db.get_active_message_watermark(child), tail_count=len(tail)) async def contents(query: str) -> list: async with TestClient(TestServer(_messages_app(adapter))) as client: response = await client.get(f"/api/sessions/{child}/messages{query}") assert response.status == 200 return [m.get("content") for m in (await response.json())["data"]] live = await contents("") assert "parent ask" in live and "child answer 2" in live assert "child question 1" not in live full = await contents("?include_compacted=true") for text in ("parent ask", "parent answer", "child question 1", "child answer 1", "child question 2", "child answer 2"): assert full.count(text) == 1, (text, full) assert full.index("parent ask") < full.index("child question 1") < full.index("child answer 2")