# Conflicts: # apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts # apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts # apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts # apps/desktop/e2e/bot-mode-roster-localized.spec.ts # apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts # apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts # apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts # apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts # apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts # apps/desktop/e2e/bot-roster-user-sections.spec.ts # apps/desktop/e2e/bot-routines-pane-narrow.spec.ts # apps/desktop/e2e/bot-row-open-recent-session.spec.ts # apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts # apps/desktop/e2e/group-composer-auto-grow.spec.ts # apps/desktop/e2e/group-create-gate-remote-roster.spec.ts # apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts # apps/desktop/e2e/hosted-room-backend-continuity.spec.ts # apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts # apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts # apps/desktop/e2e/worktree-branch-status.spec.ts # apps/desktop/electron/backend-probes.test.ts # apps/desktop/electron/connection-apply.test.ts # apps/desktop/electron/desktop-electron-pin.test.ts # apps/desktop/electron/desktop-uninstall.test.ts # apps/desktop/electron/gateway-file-download-transport.test.ts # apps/desktop/electron/gateway-stop-before-update.test.ts # apps/desktop/electron/github-api-auth.test.ts # apps/desktop/electron/registry-primary-profile-scope.test.ts # apps/desktop/electron/update-api-check.test.ts # apps/desktop/electron/update-handoff-marker.test.ts # apps/desktop/electron/venv-blocker-scan.test.ts # apps/desktop/scripts/after-extract.test.mjs # apps/desktop/scripts/local-pack-publish.test.mjs # apps/desktop/scripts/tasks-scroll.test.mjs # apps/desktop/src/app/settings/model-settings.test.tsx # apps/desktop/src/app/updates-overlay.blockers.test.tsx # apps/desktop/src/components/desktop-install-overlay.test.tsx # apps/desktop/src/lib/update-copy.test.ts # scripts/ci/check_os_marker_fakes.py # tests-js/desktop-mac-usage-descriptions.test.ts # tests-js/node-engine-alignment.test.ts # tests/agent/lsp/test_install_and_lint_fixes.py # tests/agent/test_command_token_source.py # tests/agent/test_compression_boundary_hook.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/agent/test_custom_provider_ca_probes.py # tests/agent/test_endpoint_blackhole.py # tests/agent/test_estimator_parity.py # tests/agent/test_in_place_compaction.py # tests/agent/test_moa_loop_mode.py # tests/agent/test_model_metadata.py # tests/agent/test_skill_session_platform_gate.py # tests/agent/test_skill_utils.py # tests/agent/test_ssl_ca_guard.py # tests/computer_use/test_doctor.py # tests/cron/test_codex_execution_paths.py # tests/cron/test_cron_bot_chat_delivery.py # tests/cron/test_cron_script.py # tests/cron/test_media_delivery_parity.py # tests/cron/test_misfire_catchup.py # tests/cron/test_parallel_pool.py # tests/cron/test_recurring_eagain_redispatch.py # tests/gateway/test_choice_picker.py # tests/gateway/test_control_socket_windows_live.py # tests/gateway/test_dingtalk.py # tests/gateway/test_feishu.py # tests/gateway/test_feishu_onboard.py # tests/gateway/test_gateway_shutdown.py # tests/gateway/test_matrix.py # tests/gateway/test_model_command_custom_providers.py # tests/gateway/test_reasoning_command.py # tests/gateway/test_runtime_footer.py # tests/gateway/test_session.py # tests/gateway/test_session_hygiene.py # tests/gateway/test_status.py # tests/gateway/test_teams.py # tests/gateway/test_turn_lease.py # tests/gateway/test_whatsapp_connect.py # tests/hermes_cli/test_approvals_command.py # tests/hermes_cli/test_auth_store_lock_concurrent.py # tests/hermes_cli/test_backup.py # tests/hermes_cli/test_banner_git_state.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_compat_manifest_targets.py # tests/hermes_cli/test_computer_use_cli.py # tests/hermes_cli/test_cpr_local_leak.py # tests/hermes_cli/test_dashboard_auth_gate.py # tests/hermes_cli/test_dashboard_procs_kill_grace.py # tests/hermes_cli/test_desktop_lifecycle_windows_live.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_command_install.py # tests/hermes_cli/test_fleet_config_migration_windows_live.py # tests/hermes_cli/test_gateway.py # tests/hermes_cli/test_gateway_platform_gating.py # tests/hermes_cli/test_gateway_restart_loop.py # tests/hermes_cli/test_gateway_task_probe.py # tests/hermes_cli/test_gateway_wsl.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_install_cua_driver.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_command_exports.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_linux_desktop_entry.py # tests/hermes_cli/test_local_runtime.py # tests/hermes_cli/test_local_runtime_updates.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_mcp_reload_confirm_gate.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_npm_engine.py # tests/hermes_cli/test_personality_none.py # tests/hermes_cli/test_pet_toggle.py # tests/hermes_cli/test_plan_reconciliation_windows_live.py # tests/hermes_cli/test_plugin_event_bus.py # tests/hermes_cli/test_plugin_manifest_v2.py # tests/hermes_cli/test_plugin_packs.py # tests/hermes_cli/test_plugins_cmd.py # tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py # tests/hermes_cli/test_process_identity.py # tests/hermes_cli/test_profiles.py # tests/hermes_cli/test_profiles_sidebar_cache.py # tests/hermes_cli/test_pty_bridge.py # tests/hermes_cli/test_resolve_turn_limit.py # tests/hermes_cli/test_serve_runtime_inventory.py # tests/hermes_cli/test_session_vacuum_config.py # tests/hermes_cli/test_set_config_value.py # tests/hermes_cli/test_signal_handler_kanban_worker.py # tests/hermes_cli/test_slash_confirm_windows.py # tests/hermes_cli/test_stale_pid_guard.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_telegram_managed_bot.py # tests/hermes_cli/test_tools_config.py # tests/hermes_cli/test_update_apply_shallow_count.py # tests/hermes_cli/test_update_autostash.py # tests/hermes_cli/test_update_concurrent_quarantine.py # tests/hermes_cli/test_update_fetch_failure_classifier.py # tests/hermes_cli/test_update_fleet_probe_resume_token.py # tests/hermes_cli/test_update_handoff_backend_reap.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_host_obligation.py # tests/hermes_cli/test_update_import_guard.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_update_inventory.py # tests/hermes_cli/test_update_launchd_unloaded_gateway.py # tests/hermes_cli/test_update_missing_configured_deps.py # tests/hermes_cli/test_update_modified_notice.py # tests/hermes_cli/test_update_multiplex_migration_hook.py # tests/hermes_cli/test_update_no_gateway_restart.py # tests/hermes_cli/test_update_orphan_backend_reap.py # tests/hermes_cli/test_update_parked_branch_guard.py # tests/hermes_cli/test_update_post_pull_syntax_guard.py # tests/hermes_cli/test_update_receipt.py # tests/hermes_cli/test_update_self_lock.py # tests/hermes_cli/test_update_shim_fail_closed.py # tests/hermes_cli/test_update_shim_self_lock.py # tests/hermes_cli/test_update_sqlite_remediation.py # tests/hermes_cli/test_update_stale_dashboard.py # tests/hermes_cli/test_update_stale_virtualenv.py # tests/hermes_cli/test_update_venv_health.py # tests/hermes_cli/test_update_venv_ownership_preflight.py # tests/hermes_cli/test_update_wedged_gateway.py # tests/hermes_cli/test_update_yes_flag.py # tests/hermes_cli/test_update_zip_two_phase.py # tests/hermes_cli/test_urllib_security.py # tests/hermes_cli/test_ux_messages_auth_config.py # tests/hermes_cli/test_ux_messages_startup.py # tests/hermes_cli/test_venv_holder_classifier.py # tests/hermes_cli/test_verify_console_scripts.py # tests/hermes_cli/test_verify_core_dependencies.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_server_console_ws.py # tests/hermes_cli/test_web_server_ws_ping.py # tests/hermes_cli/test_web_ui_build.py # tests/hermes_state/test_fts_rebuild_admission.py # tests/hermes_state/test_hermes_state.py # tests/plugins/memory/test_memory_lazy_install.py # tests/plugins/test_google_meet_plugin.py # tests/plugins/test_langfuse_plugin.py # tests/plugins/test_security_guidance_plugin.py # tests/plugins/test_transform_llm_output_hook.py # tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_contributor_map.py # tests/scripts/test_run_tests_parallel.py # tests/skills/test_competitor_news_monitor_skill.py # tests/skills/test_document_to_action_items_skill.py # tests/skills/test_google_workspace_setup.py # tests/skills/test_google_workspace_setup_deps.py # tests/skills/test_grounded_citations_skill.py # tests/skills/test_ip_as_logo_skill.py # tests/skills/test_live_dashboard_skill.py # tests/skills/test_mcp_oauth_remote_gateway_skill.py # tests/skills/test_office_document_skills.py # tests/skills/test_openclaw_migration.py # tests/skills/test_product_price_monitor_skill.py # tests/skills/test_scrollcraft_skill.py # tests/skills/test_setup_wizard_generator_skill.py # tests/skills/test_weekly_review_planning_skill.py # tests/test_engines_satisfiable.py # tests/test_fast_safe_load.py # tests/test_hermes_bootstrap.py # tests/test_hermes_constants.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/test_model_tools_async_bridge.py # tests/test_packaging_build_guard.py # tests/test_packaging_metadata.py # tests/test_yaml_indent_consistency.py # tests/tools/test_approval_timeout_overflow.py # tests/tools/test_base_environment.py # tests/tools/test_bot_mode_dm.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_hardening.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_use_cli.py # tests/tools/test_clipboard.py # tests/tools/test_code_execution.py # tests/tools/test_code_execution_modes.py # tests/tools/test_code_execution_windows_env.py # tests/tools/test_computer_use.py # tests/tools/test_delegate_liveness_timeout.py # tests/tools/test_execute_code_approval_cluster.py # tests/tools/test_execution_flag_detection.py # tests/tools/test_fal_common.py # tests/tools/test_file_operations.py # tests/tools/test_file_tools.py # tests/tools/test_file_tools_cwd_resolution.py # tests/tools/test_file_tools_live.py # tests/tools/test_lazy_deps.py # tests/tools/test_lazy_deps_durable_target.py # tests/tools/test_lazy_deps_managed.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_local_tempdir.py # tests/tools/test_macos_protected_search.py # tests/tools/test_mcp_npx_cached_bin.py # tests/tools/test_oneshot_completion_linger.py # tests/tools/test_process_registry.py # tests/tools/test_read_file_schema_gating.py # tests/tools/test_skill_improvements.py # tests/tools/test_skills_sync.py # tests/tools/test_termux_api_detection.py # tests/tools/test_tirith_security.py # tests/tools/test_transcription_tools.py # tests/tools/test_tts_streaming.py # tests/tools/test_wake_word.py # tests/tui_gateway/test_compute_host_borrowed_lease.py # tests/tui_gateway/test_compute_host_turn_protocol.py # tests/tui_gateway/test_isolated_orphan_activity.py # tests/tui_gateway/test_protocol.py # tests/tui_gateway/test_slash_worker_profile_home.py # tests/tui_gateway/test_subprocess_encoding.py # tests/tui_gateway/test_tui_gateway_server.py # ui-tui/src/__tests__/terminalParity.test.ts # ui-tui/src/__tests__/termuxComposerLayout.test.ts # ui-tui/src/__tests__/textInputFastEcho.test.ts
499 lines
20 KiB
Python
499 lines
20 KiB
Python
"""Tests for context compression persistence in the gateway.
|
||
|
||
Verifies that when context compression fires during run_conversation(),
|
||
the compressed messages are properly persisted to both SQLite (via the
|
||
agent) and JSONL (via the gateway).
|
||
|
||
Bug scenario (pre-fix):
|
||
1. Gateway loads 200-message history, passes to agent
|
||
2. Agent's run_conversation() compresses to ~30 messages mid-run
|
||
3. _compress_context() resets _last_flushed_db_idx = 0
|
||
4. On exit, _flush_messages_to_session_db() calculates:
|
||
flush_from = max(len(conversation_history=200), _last_flushed_db_idx=0) = 200
|
||
5. messages[200:] is empty (only ~30 messages after compression)
|
||
6. Nothing written to new session's SQLite — compressed context lost
|
||
7. Gateway's history_offset was still 200, producing empty new_messages
|
||
8. Fallback wrote only user/assistant pair — summary lost
|
||
"""
|
||
|
||
import os
|
||
import tempfile
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Part 1: Agent-side — _flush_messages_to_session_db after compression
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestFlushAfterCompression:
|
||
"""Verify that compressed messages are flushed to the new session's SQLite
|
||
even when conversation_history (from the original session) is longer than
|
||
the compressed messages list."""
|
||
|
||
def _make_agent(self, session_db):
|
||
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
|
||
from run_agent import AIAgent
|
||
agent = AIAgent(
|
||
api_key="test-key",
|
||
base_url="https://openrouter.ai/api/v1",
|
||
model="test/model",
|
||
quiet_mode=True,
|
||
session_db=session_db,
|
||
session_id="original-session",
|
||
skip_context_files=True,
|
||
skip_memory=True,
|
||
)
|
||
return agent
|
||
|
||
def test_flush_after_compression_with_long_history(self):
|
||
"""The actual bug: conversation_history longer than compressed messages.
|
||
|
||
Before the fix, flush_from = max(len(conversation_history), 0) = 200,
|
||
but messages only has ~30 entries, so messages[200:] is empty.
|
||
After the fix, conversation_history is cleared to None after compression,
|
||
so flush_from = max(0, 0) = 0, and ALL compressed messages are written.
|
||
"""
|
||
from hermes_state import SessionDB
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
db_path = Path(tmpdir) / "test.db"
|
||
db = SessionDB(db_path=db_path)
|
||
|
||
agent = self._make_agent(db)
|
||
|
||
# Simulate the original long history (200 messages)
|
||
original_history = [
|
||
{"role": "user" if i % 2 == 0 else "assistant",
|
||
"content": f"message {i}"}
|
||
for i in range(200)
|
||
]
|
||
|
||
# First, flush original messages to the original session
|
||
agent._flush_messages_to_session_db(original_history, [])
|
||
original_rows = db.get_messages("original-session")
|
||
assert len(original_rows) == 200
|
||
|
||
# Now simulate compression: new session, reset idx, shorter messages
|
||
agent.session_id = "compressed-session"
|
||
db.create_session(session_id="compressed-session", source="test")
|
||
agent._last_flushed_db_idx = 0
|
||
|
||
# The compressed messages (summary + tail + new turn)
|
||
compressed_messages = [
|
||
{"role": "user", "content": "[CONTEXT COMPACTION] Summary of work..."},
|
||
{"role": "user", "content": "What should we do next?"},
|
||
{"role": "assistant", "content": "Let me check..."},
|
||
{"role": "user", "content": "new question"},
|
||
{"role": "assistant", "content": "new answer"},
|
||
]
|
||
|
||
# THE BUG: passing the original history as conversation_history
|
||
# causes flush_from = max(200, 0) = 200, skipping everything.
|
||
# After the fix, conversation_history should be None.
|
||
agent._flush_messages_to_session_db(compressed_messages, None)
|
||
|
||
new_rows = db.get_messages("compressed-session")
|
||
assert len(new_rows) == 5, (
|
||
f"Expected 5 compressed messages in new session, got {len(new_rows)}. "
|
||
f"Compression persistence bug: messages not written to SQLite."
|
||
)
|
||
db.close()
|
||
|
||
def test_flush_with_stale_history_loses_messages(self):
|
||
"""Stale conversation_history no longer causes data loss."""
|
||
from hermes_state import SessionDB
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
db_path = Path(tmpdir) / "test.db"
|
||
db = SessionDB(db_path=db_path)
|
||
|
||
agent = self._make_agent(db)
|
||
|
||
# Simulate compression reset
|
||
agent.session_id = "new-session"
|
||
db.create_session(session_id="new-session", source="test")
|
||
agent._last_flushed_db_idx = 0
|
||
|
||
compressed = [
|
||
{"role": "user", "content": "summary"},
|
||
{"role": "assistant", "content": "continuing..."},
|
||
]
|
||
|
||
# Stale history longer than messages: the old positional flush
|
||
# sliced past the end and dropped both messages (#46053).
|
||
stale_history = [{"role": "user", "content": f"msg{i}"} for i in range(100)]
|
||
agent._flush_messages_to_session_db(compressed, stale_history)
|
||
|
||
rows = db.get_messages("new-session")
|
||
assert len(rows) == 2
|
||
assert [row["content"] for row in rows] == ["summary", "continuing..."]
|
||
db.close()
|
||
|
||
def test_in_place_compression_rebaseline_prevents_duplicate_compacted_rows(self):
|
||
"""In-place compaction already persisted the compacted transcript.
|
||
|
||
Regression for the 2026-06-26 SRE compression loop: archive_and_compact()
|
||
inserted a compacted active block, then the same turn continued with
|
||
conversation_history=None and _flush_messages_to_session_db() appended
|
||
the compacted dicts again, doubling live context.
|
||
"""
|
||
from agent.conversation_compression import conversation_history_after_compression
|
||
from hermes_state import SessionDB
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
db_path = Path(tmpdir) / "test.db"
|
||
db = SessionDB(db_path=db_path)
|
||
|
||
agent = self._make_agent(db)
|
||
agent._ensure_db_session()
|
||
|
||
original_history = [
|
||
{"role": "user", "content": "old question"},
|
||
{"role": "assistant", "content": "old answer"},
|
||
]
|
||
agent._flush_messages_to_session_db(original_history, [])
|
||
assert [row["content"] for row in db.get_messages("original-session")] == [
|
||
"old question",
|
||
"old answer",
|
||
]
|
||
|
||
compacted = [
|
||
{"role": "assistant", "content": "[CONTEXT COMPACTION] summary"},
|
||
{"role": "user", "content": "recent question"},
|
||
{"role": "assistant", "content": "recent answer"},
|
||
]
|
||
db.archive_and_compact("original-session", compacted)
|
||
setattr(agent, "_last_compaction_in_place", True)
|
||
agent._last_flushed_db_idx = 0
|
||
|
||
# Same agent turn continues after compaction. The compacted dicts
|
||
# must be treated as already-persisted history; only later appends
|
||
# should be flushed.
|
||
post_compaction_history = conversation_history_after_compression(
|
||
agent, compacted
|
||
)
|
||
assert post_compaction_history is not None
|
||
assert post_compaction_history is not compacted
|
||
assert post_compaction_history == compacted
|
||
|
||
messages = compacted + [
|
||
{"role": "tool", "content": "tool result"},
|
||
{"role": "assistant", "content": "final answer"},
|
||
]
|
||
agent._flush_messages_to_session_db(messages, post_compaction_history)
|
||
|
||
rows = db.get_messages("original-session")
|
||
assert [row["content"] for row in rows] == [
|
||
"[CONTEXT COMPACTION] summary",
|
||
"recent question",
|
||
"recent answer",
|
||
"tool result",
|
||
"final answer",
|
||
]
|
||
db.close()
|
||
|
||
def test_abort_after_in_place_compaction_preserves_flush_baseline(self):
|
||
"""An aborted retry must survive flush, restart, and resume."""
|
||
from agent.conversation_compression import (
|
||
compress_context,
|
||
conversation_history_after_compression,
|
||
)
|
||
from hermes_state import SessionDB
|
||
|
||
class SuccessCompressor:
|
||
_last_compress_aborted = False
|
||
_last_summary_error = None
|
||
compression_count = 1
|
||
_last_compression_made_progress = True
|
||
_last_summary_fallback_used = False
|
||
last_compression_rough_tokens = 0
|
||
last_prompt_tokens = 0
|
||
last_completion_tokens = 0
|
||
awaiting_real_usage_after_compression = False
|
||
|
||
def compress(self, _messages, **_kwargs):
|
||
return [
|
||
{"role": "user", "content": "[summary] earlier state"},
|
||
{"role": "assistant", "content": "retained tail"},
|
||
]
|
||
|
||
class AbortCompressor:
|
||
_last_compress_aborted = False
|
||
_last_summary_error = "simulated auxiliary timeout"
|
||
compression_count = 2
|
||
_last_compression_made_progress = False
|
||
_last_summary_fallback_used = False
|
||
last_compression_rough_tokens = 0
|
||
last_prompt_tokens = 0
|
||
last_completion_tokens = 0
|
||
awaiting_real_usage_after_compression = False
|
||
|
||
def compress(self, messages, **_kwargs):
|
||
self._last_compress_aborted = True
|
||
return messages
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
db_path = Path(tmpdir) / "test.db"
|
||
db = SessionDB(db_path=db_path)
|
||
agent = self._make_agent(db)
|
||
agent.compression_in_place = True
|
||
original = [
|
||
{"role": "user", "content": "old question"},
|
||
{"role": "assistant", "content": "old answer"},
|
||
]
|
||
agent._flush_messages_to_session_db(original, [])
|
||
|
||
agent.context_compressor = SuccessCompressor()
|
||
compacted, _ = compress_context(
|
||
agent, original, "system", approx_tokens=100_000
|
||
)
|
||
history = conversation_history_after_compression(
|
||
agent, compacted, None
|
||
)
|
||
|
||
messages = compacted + [
|
||
{"role": "user", "content": "new request"},
|
||
{"role": "assistant", "content": "new answer"},
|
||
]
|
||
agent.context_compressor = AbortCompressor()
|
||
returned, _ = compress_context(
|
||
agent, messages, "system", approx_tokens=100_000
|
||
)
|
||
history = conversation_history_after_compression(
|
||
agent, returned, history
|
||
)
|
||
agent._flush_messages_to_session_db(returned, history)
|
||
|
||
db.close()
|
||
resumed_db = SessionDB(db_path=db_path)
|
||
assert [message["content"] for message in resumed_db.get_messages_as_conversation(
|
||
agent.session_id
|
||
)] == [
|
||
"[summary] earlier state",
|
||
"retained tail",
|
||
"new request",
|
||
"new answer",
|
||
]
|
||
resumed_db.close()
|
||
|
||
def test_rotation_child_session_flushes_full_compressed_transcript_with_markers(self):
|
||
"""Regression for #57491: live cached-agent markers must not block child flush."""
|
||
from agent.conversation_compression import compress_context
|
||
from hermes_state import SessionDB
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
db_path = Path(tmpdir) / "test.db"
|
||
db = SessionDB(db_path=db_path)
|
||
parent_sid = "20260701_152840_parent"
|
||
db.create_session(parent_sid, "gateway", model="test/model")
|
||
|
||
agent = self._make_agent(db)
|
||
agent.session_id = parent_sid
|
||
agent.compression_in_place = False
|
||
agent._ensure_db_session()
|
||
|
||
# Plain marked messages only: the exact-equality assertion below
|
||
# relies on `compressed` containing no message that _flush filters
|
||
# for a reason INDEPENDENT of _db_persisted (ephemeral scaffolding,
|
||
# synthetic recovery turns). Keep this fixture free of such messages
|
||
# or the row count would legitimately differ from len(compressed).
|
||
# The transcript must also be large enough that the provider-less
|
||
# static fallback net-shrinks it (middle drops must outweigh the
|
||
# fixed compaction marker overhead), or the no-growth commit guard
|
||
# correctly refuses the rotation this test exercises. Sized for
|
||
# the lean tail default: the 10K-token tail floor must leave a
|
||
# substantial compressible middle (~2K chars/message × 40 ≈ 20K
|
||
# estimated tokens total).
|
||
messages = [
|
||
{
|
||
"role": "user" if i % 2 == 0 else "assistant",
|
||
"content": f"message {i} " + "x" * 2000,
|
||
"_db_persisted": True,
|
||
}
|
||
for i in range(40)
|
||
]
|
||
|
||
with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
|
||
compressed, _ = compress_context(
|
||
agent, messages, approx_tokens=100_000, system_message="sys"
|
||
)
|
||
|
||
assert agent.session_id != parent_sid
|
||
child_sid = agent.session_id
|
||
|
||
agent._flush_messages_to_session_db(compressed, None)
|
||
|
||
child_rows = db.get_messages(child_sid)
|
||
assert len(child_rows) == len(compressed), (
|
||
f"Expected {len(compressed)} rows in child session, got {len(child_rows)}. "
|
||
f"_db_persisted marker propagation bug (#57491)."
|
||
)
|
||
db.close()
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Part 2: Gateway-side — history_offset after session split
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
|
||
|
||
class TestStoredPromptCwdDrift:
|
||
"""Verify that stored system prompts are rejected when cwd changed."""
|
||
|
||
def _make_agent(self, model="test/model", provider="openrouter"):
|
||
class _Agent:
|
||
pass
|
||
|
||
agent = _Agent()
|
||
agent.model = model
|
||
agent.provider = provider
|
||
return agent
|
||
|
||
@staticmethod
|
||
def _host_block(cwd: str) -> str:
|
||
"""A stored prompt fragment shaped like the real host-info block.
|
||
|
||
``build_environment_hints`` always emits ``User home directory:``
|
||
immediately before the working-directory line, and the staleness check
|
||
anchors on that pair so user project files can't shadow the real value.
|
||
Fixtures must therefore include the anchor or they stop exercising the
|
||
cwd path at all.
|
||
"""
|
||
return (
|
||
"Host: Linux (6.16.0)\n"
|
||
"User home directory: /home/tester\n"
|
||
f"Current working directory: {cwd}\n"
|
||
)
|
||
|
||
def test_stored_prompt_stale_when_cwd_differs(self):
|
||
"""Different cwd should force a prompt rebuild."""
|
||
from unittest.mock import patch
|
||
from agent.conversation_loop import _stored_prompt_matches_runtime
|
||
|
||
agent = self._make_agent()
|
||
stored_prompt = (
|
||
self._host_block("/project/old")
|
||
+ "Model: test/model\n"
|
||
"Provider: openrouter\n"
|
||
)
|
||
|
||
with patch("os.getcwd", return_value="/project/new"):
|
||
assert _stored_prompt_matches_runtime(agent, stored_prompt) is False, (
|
||
"Expected False when stored cwd differs from current cwd"
|
||
)
|
||
|
||
def test_stored_prompt_fresh_when_cwd_matches(self):
|
||
"""Matching cwd should allow prompt reuse."""
|
||
from unittest.mock import patch
|
||
from agent.conversation_loop import _stored_prompt_matches_runtime
|
||
|
||
agent = self._make_agent()
|
||
current_cwd = str(Path("/project/current"))
|
||
stored_prompt = (
|
||
self._host_block(current_cwd)
|
||
+ "Model: test/model\n"
|
||
"Provider: openrouter\n"
|
||
)
|
||
|
||
with patch("os.getcwd", return_value=current_cwd):
|
||
assert _stored_prompt_matches_runtime(agent, stored_prompt) is True, (
|
||
"Expected True when stored cwd matches current cwd"
|
||
)
|
||
|
||
def test_project_context_cannot_force_a_rebuild(self):
|
||
"""🔴 CACHE INVARIANT: user project text must never invalidate the prompt.
|
||
|
||
The prompt embeds AGENTS.md / CLAUDE.md / .cursorrules in the context
|
||
tier, which sits AFTER the host-info block. A whole-prompt scan for
|
||
``Current working directory:`` therefore matched the user's own file
|
||
and compared runtime state against project prose. That mismatch never
|
||
clears, so the check rejected the stored prompt on EVERY turn —
|
||
rebuilding the system prompt each message and destroying the prefix
|
||
cache for the entire session. Strictly worse than the staleness this
|
||
check exists to catch.
|
||
"""
|
||
from unittest.mock import patch
|
||
from agent.conversation_loop import _stored_prompt_matches_runtime
|
||
|
||
agent = self._make_agent()
|
||
current_cwd = str(Path("/project/current"))
|
||
stored_prompt = (
|
||
self._host_block(current_cwd)
|
||
+ "\n# AGENTS.md\n\n"
|
||
"Our deploy convention:\n\n"
|
||
"Current working directory: /srv/decoy\n\n"
|
||
"Always run make before pushing.\n\n"
|
||
"Model: test/model\n"
|
||
"Provider: openrouter\n"
|
||
)
|
||
|
||
with patch("os.getcwd", return_value=current_cwd):
|
||
assert _stored_prompt_matches_runtime(agent, stored_prompt) is True, (
|
||
"A project file that merely MENTIONS 'Current working "
|
||
"directory:' must not invalidate the prompt — that would "
|
||
"rebuild every turn and break the prefix cache"
|
||
)
|
||
|
||
def test_project_context_cannot_mask_real_drift(self):
|
||
"""The inverse: project text must not fake a match either.
|
||
|
||
A stored prompt built in /project/old whose embedded AGENTS.md happens
|
||
to name the NEW cwd must still be rejected — otherwise project prose
|
||
could suppress genuine drift detection.
|
||
"""
|
||
from unittest.mock import patch
|
||
from agent.conversation_loop import _stored_prompt_matches_runtime
|
||
|
||
agent = self._make_agent()
|
||
stored_prompt = (
|
||
self._host_block("/project/old")
|
||
+ "\n# AGENTS.md\n\n"
|
||
"Current working directory: /project/new\n\n"
|
||
"Model: test/model\n"
|
||
"Provider: openrouter\n"
|
||
)
|
||
|
||
with patch("os.getcwd", return_value="/project/new"):
|
||
assert _stored_prompt_matches_runtime(agent, stored_prompt) is False, (
|
||
"Embedded project text naming the new cwd must not mask real "
|
||
"drift in the host-info block"
|
||
)
|
||
|
||
|
||
|
||
|
||
|
||
def test_built_prompt_contains_platform_line(self):
|
||
"""The built system prompt must carry a Platform: line so drift detection works."""
|
||
import tempfile
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
from hermes_state import SessionDB
|
||
from run_agent import AIAgent
|
||
from agent.system_prompt import build_system_prompt_parts
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
db = SessionDB(db_path=Path(tmpdir) / "test.db")
|
||
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
|
||
agent = AIAgent(
|
||
api_key="test-key",
|
||
base_url="https://openrouter.ai/api/v1",
|
||
model="test/model",
|
||
provider="openrouter",
|
||
quiet_mode=True,
|
||
session_db=db,
|
||
session_id="platform-test",
|
||
skip_context_files=True,
|
||
skip_memory=True,
|
||
)
|
||
agent.platform = "cli"
|
||
parts = build_system_prompt_parts(agent)
|
||
assert "Platform: cli" in parts["volatile"], (
|
||
"Built prompt missing 'Platform: cli' — drift detection cannot read it"
|
||
)
|
||
db.close()
|