headroom/tests/test_memory/test_traffic_learner.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

2494 lines
90 KiB
Python
Raw Permalink Normal View History

"""Tests for the Traffic Pattern Learner.
Tests pattern extraction from proxy traffic without requiring
a real memory backend.
"""
from __future__ import annotations
fix(learn): run project discovery off the event loop (#2731) ## Description `TrafficLearner.flush_to_file` is a coroutine, but it called `plugin.discover_projects()` inline. That function walks the filesystem to decode escaped project directory names — in `learn/plugins/claude.py`, `_greedy_path_decode` recurses through `iterdir()` at every level and tries each tokenization of each child, backtracking on a miss — so on a large home tree it runs for minutes. Doing that on the event loop freezes uvicorn for the whole window. The port keeps accepting TCP, but `/readyz` never answers, so a supervisor health-checking the proxy kills a process that is merely busy. Field thread dumps show exactly that: ``` Current thread (most recent call first): File "python3.12/pathlib.py", line 1056 in iterdir File "headroom/learn/plugins/claude.py", line 454 in _greedy_path_decode File "headroom/learn/plugins/claude.py", line 478 in _greedy_path_decode File "headroom/learn/plugins/claude.py", line 478 in _greedy_path_decode File "headroom/learn/plugins/claude.py", line 426 in _decode_project_path File "headroom/learn/plugins/claude.py", line 71 in discover_projects File "headroom/memory/traffic_learner.py", line 591 in flush_to_file File "headroom/memory/traffic_learner.py", line 535 in _flush_worker File "python3.12/asyncio/events.py", line 88 in _run File "python3.12/asyncio/base_events.py", line 1999 in _run_once File "python3.12/asyncio/base_events.py", line 645 in run_forever File "uvicorn/server.py", line 75 in run File "headroom/proxy/server.py", line 4992 in run_server ``` Accompanying signals from the same incidents: port accepts TCP, `/readyz` times out, process CPU 2-13s across the window (I/O bound, not spinning), proxy log silent 66-336s. ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/memory/traffic_learner.py`: `flush_to_file` now awaits `asyncio.to_thread(plugin.discover_projects)` instead of calling it inline. `asyncio` was already imported. The result is cached per learner (`_project_roots_cache`), so the steady-state flush path pays nothing for the thread hop. - `tests/test_memory/test_traffic_learner.py`: added `test_discover_projects_does_not_block_the_event_loop`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uv run --frozen --extra dev pytest tests/test_memory/test_traffic_learner.py -q .................................................. [100%] ============================= 152 passed in 2.93s ============================== $ uvx ruff check headroom/memory/traffic_learner.py tests/test_memory/test_traffic_learner.py All checks passed! $ uvx ruff format --check headroom/memory/traffic_learner.py tests/test_memory/test_traffic_learner.py 2 files already formatted $ uv run --frozen --extra dev mypy headroom/memory/traffic_learner.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: macOS 15.6 arm64, Python 3.10.18, pytest 9.0.3, branch off `main` @ `01df2452`. - Exact command / steps: reverted only the one-line source change in the working tree (`await asyncio.to_thread(plugin.discover_projects)` back to `plugin.discover_projects()`), left the new test in place, ran `uv run --frozen --extra dev pytest tests/test_memory/test_traffic_learner.py -k does_not_block -q`, then restored the line and re-ran the full file. - Observed result: without the change the test fails — `flush_to_file` runs to completion synchronously the moment the task is created, so the loop never regains control while `discover_projects` is parked on a `threading.Event`. With the change the loop stays responsive and the flush completes once discovery returns. Full file: 152 passed. - Not tested: no live proxy run against a multi-minute real home tree; the blocking behaviour is reproduced deterministically in the test instead. The thread dump above is captured field evidence, not a run in this environment. Failing output with the fix reverted: ```text $ uv run --frozen --extra dev pytest tests/test_memory/test_traffic_learner.py -k does_not_block -q tests/test_memory/test_traffic_learner.py:1254: in test_discover_projects_does_not_block_the_event_loop assert not flush.done() E AssertionError: assert not True E + where True = <built-in method done of _asyncio.Task object at 0x10882dff0>() E + where <built-in method done of _asyncio.Task object at 0x10882dff0> = <Task finished name='Task-1' coro=<TrafficLearner.flush_to_file() done ...>>.done ========================= 1 failed, 151 deselected in 5.51s ========================= ``` ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes - Documentation: N/A — no user-facing behaviour or interface change. - Bounding `_greedy_path_decode`'s backtracking is the real cost fix and belongs in its own change. This one only stops a slow walk from taking the server's liveness with it.
2026-08-03 15:00:52 +02:00
import asyncio
import threading
from datetime import datetime, timedelta, timezone
import pytest
from headroom.memory.traffic_learner import (
ExtractedPattern,
PatternCategory,
TrafficLearner,
_bash_binaries_match,
_bash_first_binary,
_classify_error,
fix(traffic-learner): tighten matchers and drop contradictions The recovery matchers paired any failed and successful tool call within a 5-call window with no semantic check that the pair was actually a retry. This produced confidently-wrong rules like: File `state.rs` does not exist. The correct path is `lib.rs`. …where the user simply read two unrelated files in the same directory. Across sessions the same user can also typo in opposite directions, producing directly contradictory rules side by side. This commit adds three structural checks: 1. Read recovery: require the failed and successful basenames to be identical or close in Levenshtein distance. Rejects the "same dir, different file" case that was the most common noise source. 2. Bash recovery: require both commands to share a binary (allowing path-prefixed variants and short prefix-versions like `python` ↔ `python3`) AND either have low normalized edit distance or share a substantive non-flag token. Rejects pairs that share only the binary name but differ in every meaningful argument. 3. Contradiction filter on flush: detect A→B and B→A pairs in error_recovery patterns and drop both. They almost always indicate opposite-direction typos in different sessions, not stable advice. Also: stash failed_path in metadata so the contradiction filter and downstream consumers can reason about pairs without parsing content. Tests: 13 new tests covering the heuristics directly. Existing tests exercising legitimate recoveries (`python`→`python3`, `ruff`→`.venv/bin/ruff`, `pip install`→success) continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:26:20 +09:00
_commands_related_as_retry,
_drop_contradictions,
_is_error,
_levenshtein,
feat(learn): live flush of traffic patterns to agent-native context files Replaces the previous shutdown-only flush with a debounced, near-real-time dirty-flag flush worker that writes patterns into the correct CLAUDE.md / MEMORY.md bucket as traffic accumulates. - New FLUSH_DEBOUNCE_SECONDS gate (10s) prevents context-file thrash on bursty traffic while keeping updates "live" from the user's perspective. - TrafficLearner.start() now spawns a _flush_worker alongside the save worker; _accumulate() sets a dirty flag; _flush_worker() calls flush_to_file() when dirty and past the debounce window. - flush_to_file() now reads *both* persisted rows (memory.db) and the in-memory accumulator via _load_persisted_patterns_from_sqlite and _collect_all_patterns, so patterns survive proxy restarts and the agent-native files converge toward the full learned set. - Patterns are bucketed per-project via the learn plugin registry (plugin.discover_projects()) and anchored to project roots through longest-matching-path on content or entity_refs (_project_for_pattern). Un-anchored patterns are dropped. - Patterns are routed by PatternCategory to either CONTEXT_FILE (CLAUDE.md) or MEMORY_FILE (MEMORY.md) via _patterns_to_recommendations + _CATEGORY_TO_TARGET. - Live flushes require evidence_count >= 2; shutdown flushes accept single-evidence rows to avoid losing last-session signal. Adds tests for project routing, persisted-pattern loading, category routing, and the debounced flush worker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:00:15 +02:00
_load_persisted_patterns_from_sqlite,
_normalize_bash_for_hash,
_parse_iso_timestamp,
fix(traffic-learner): tighten matchers and drop contradictions The recovery matchers paired any failed and successful tool call within a 5-call window with no semantic check that the pair was actually a retry. This produced confidently-wrong rules like: File `state.rs` does not exist. The correct path is `lib.rs`. …where the user simply read two unrelated files in the same directory. Across sessions the same user can also typo in opposite directions, producing directly contradictory rules side by side. This commit adds three structural checks: 1. Read recovery: require the failed and successful basenames to be identical or close in Levenshtein distance. Rejects the "same dir, different file" case that was the most common noise source. 2. Bash recovery: require both commands to share a binary (allowing path-prefixed variants and short prefix-versions like `python` ↔ `python3`) AND either have low normalized edit distance or share a substantive non-flag token. Rejects pairs that share only the binary name but differ in every meaningful argument. 3. Contradiction filter on flush: detect A→B and B→A pairs in error_recovery patterns and drop both. They almost always indicate opposite-direction typos in different sessions, not stable advice. Also: stash failed_path in metadata so the contradiction filter and downstream consumers can reason about pairs without parsing content. Tests: 13 new tests covering the heuristics directly. Existing tests exercising legitimate recoveries (`python`→`python3`, `ruff`→`.venv/bin/ruff`, `pip install`→success) continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:26:20 +09:00
_paths_related_as_typo,
feat(learn): live flush of traffic patterns to agent-native context files Replaces the previous shutdown-only flush with a debounced, near-real-time dirty-flag flush worker that writes patterns into the correct CLAUDE.md / MEMORY.md bucket as traffic accumulates. - New FLUSH_DEBOUNCE_SECONDS gate (10s) prevents context-file thrash on bursty traffic while keeping updates "live" from the user's perspective. - TrafficLearner.start() now spawns a _flush_worker alongside the save worker; _accumulate() sets a dirty flag; _flush_worker() calls flush_to_file() when dirty and past the debounce window. - flush_to_file() now reads *both* persisted rows (memory.db) and the in-memory accumulator via _load_persisted_patterns_from_sqlite and _collect_all_patterns, so patterns survive proxy restarts and the agent-native files converge toward the full learned set. - Patterns are bucketed per-project via the learn plugin registry (plugin.discover_projects()) and anchored to project roots through longest-matching-path on content or entity_refs (_project_for_pattern). Un-anchored patterns are dropped. - Patterns are routed by PatternCategory to either CONTEXT_FILE (CLAUDE.md) or MEMORY_FILE (MEMORY.md) via _patterns_to_recommendations + _CATEGORY_TO_TARGET. - Live flushes require evidence_count >= 2; shutdown flushes accept single-evidence rows to avoid losing last-session signal. Adds tests for project routing, persisted-pattern loading, category routing, and the debounced flush worker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:00:15 +02:00
_patterns_to_recommendations,
_project_for_pattern,
_refine_error_recovery,
)
UTC = timezone.utc
# =============================================================================
# Error Classification Tests
# =============================================================================
class TestErrorClassification:
def test_file_not_found(self):
assert _classify_error("No such file or directory: foo.py") == "file_not_found"
assert _classify_error("FileNotFoundError: [Errno 2]") == "file_not_found"
def test_command_not_found(self):
assert _classify_error("zsh: command not found: ruff") == "command_not_found"
def test_module_not_found(self):
assert _classify_error("ModuleNotFoundError: No module named 'foo'") == "module_not_found"
def test_permission_denied(self):
assert _classify_error("Permission denied: /etc/shadow") == "permission_denied"
def test_not_an_error(self):
assert _classify_error("Everything is fine, tests passed!") is None
assert _classify_error("") is None
def test_is_error_helper(self):
assert _is_error("No such file or directory")
assert not _is_error("All tests passed")
assert not _is_error("")
assert not _is_error("short")
fix(traffic-learner): tighten matchers and drop contradictions The recovery matchers paired any failed and successful tool call within a 5-call window with no semantic check that the pair was actually a retry. This produced confidently-wrong rules like: File `state.rs` does not exist. The correct path is `lib.rs`. …where the user simply read two unrelated files in the same directory. Across sessions the same user can also typo in opposite directions, producing directly contradictory rules side by side. This commit adds three structural checks: 1. Read recovery: require the failed and successful basenames to be identical or close in Levenshtein distance. Rejects the "same dir, different file" case that was the most common noise source. 2. Bash recovery: require both commands to share a binary (allowing path-prefixed variants and short prefix-versions like `python` ↔ `python3`) AND either have low normalized edit distance or share a substantive non-flag token. Rejects pairs that share only the binary name but differ in every meaningful argument. 3. Contradiction filter on flush: detect A→B and B→A pairs in error_recovery patterns and drop both. They almost always indicate opposite-direction typos in different sessions, not stable advice. Also: stash failed_path in metadata so the contradiction filter and downstream consumers can reason about pairs without parsing content. Tests: 13 new tests covering the heuristics directly. Existing tests exercising legitimate recoveries (`python`→`python3`, `ruff`→`.venv/bin/ruff`, `pip install`→success) continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:26:20 +09:00
# =============================================================================
# Recovery-pair relatedness heuristics
# =============================================================================
class TestPathsRelatedAsTypo:
def test_identical_basename_different_dir_is_typo(self):
# Same file in two locations — common path-typo case.
assert _paths_related_as_typo("/a/state.rs", "/b/state.rs")
def test_close_basename_is_typo(self):
assert _paths_related_as_typo("/a/staet.rs", "/a/state.rs")
assert _paths_related_as_typo("/a/App.tsx", "/a/app.tsx")
def test_unrelated_files_in_same_dir_rejected(self):
# The motivating bug: state.rs and lib.rs are unrelated files,
# not typos, and should never be paired into a recovery rule.
assert not _paths_related_as_typo("/src-tauri/src/state.rs", "/src-tauri/src/lib.rs")
fix(traffic-learner): tighten matchers and drop contradictions The recovery matchers paired any failed and successful tool call within a 5-call window with no semantic check that the pair was actually a retry. This produced confidently-wrong rules like: File `state.rs` does not exist. The correct path is `lib.rs`. …where the user simply read two unrelated files in the same directory. Across sessions the same user can also typo in opposite directions, producing directly contradictory rules side by side. This commit adds three structural checks: 1. Read recovery: require the failed and successful basenames to be identical or close in Levenshtein distance. Rejects the "same dir, different file" case that was the most common noise source. 2. Bash recovery: require both commands to share a binary (allowing path-prefixed variants and short prefix-versions like `python` ↔ `python3`) AND either have low normalized edit distance or share a substantive non-flag token. Rejects pairs that share only the binary name but differ in every meaningful argument. 3. Contradiction filter on flush: detect A→B and B→A pairs in error_recovery patterns and drop both. They almost always indicate opposite-direction typos in different sessions, not stable advice. Also: stash failed_path in metadata so the contradiction filter and downstream consumers can reason about pairs without parsing content. Tests: 13 new tests covering the heuristics directly. Existing tests exercising legitimate recoveries (`python`→`python3`, `ruff`→`.venv/bin/ruff`, `pip install`→success) continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:26:20 +09:00
assert not _paths_related_as_typo("/x/models.py", "/x/views.py")
def test_empty_or_equal_paths_rejected(self):
assert not _paths_related_as_typo("", "/a/x")
assert not _paths_related_as_typo("/a/x", "")
assert not _paths_related_as_typo("/a/x", "/a/x")
class TestCommandsRelatedAsRetry:
def test_python_to_python3_is_retry(self):
assert _commands_related_as_retry("python test.py", "python3 test.py")
def test_path_prefixed_binary_is_retry(self):
assert _commands_related_as_retry("ruff check .", ".venv/bin/ruff check .")
def test_extra_flag_is_retry(self):
assert _commands_related_as_retry("cargo build", "cargo build --release")
fix(traffic-learner): tighten matchers and drop contradictions The recovery matchers paired any failed and successful tool call within a 5-call window with no semantic check that the pair was actually a retry. This produced confidently-wrong rules like: File `state.rs` does not exist. The correct path is `lib.rs`. …where the user simply read two unrelated files in the same directory. Across sessions the same user can also typo in opposite directions, producing directly contradictory rules side by side. This commit adds three structural checks: 1. Read recovery: require the failed and successful basenames to be identical or close in Levenshtein distance. Rejects the "same dir, different file" case that was the most common noise source. 2. Bash recovery: require both commands to share a binary (allowing path-prefixed variants and short prefix-versions like `python` ↔ `python3`) AND either have low normalized edit distance or share a substantive non-flag token. Rejects pairs that share only the binary name but differ in every meaningful argument. 3. Contradiction filter on flush: detect A→B and B→A pairs in error_recovery patterns and drop both. They almost always indicate opposite-direction typos in different sessions, not stable advice. Also: stash failed_path in metadata so the contradiction filter and downstream consumers can reason about pairs without parsing content. Tests: 13 new tests covering the heuristics directly. Existing tests exercising legitimate recoveries (`python`→`python3`, `ruff`→`.venv/bin/ruff`, `pip install`→success) continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:26:20 +09:00
def test_different_binaries_rejected(self):
assert not _commands_related_as_retry("grep -n foo bar.rs", "find . -name foo")
def test_same_binary_unrelated_args_rejected(self):
# The motivating bug: two grep calls sharing nothing but the
# binary should not pair up. Different needles, different files.
failed = (
'grep -nE "smoke|HEADROOM_SMOKE_TEST_TIMEOUT|smoke_test" '
"/Users/x/src-tauri/src/tool_manager.rs 2>&1 | head -20"
)
success = (
'grep -nE "fn hf_hub_cache_dir|HF_HOME|HUGGINGFACE_HUB_CACHE" '
"/Users/x/src-tauri/src/state.rs 2>&1 | head -10"
)
assert not _commands_related_as_retry(failed, success)
def test_empty_or_equal_commands_rejected(self):
assert not _commands_related_as_retry("", "ls")
assert not _commands_related_as_retry("ls", "")
assert not _commands_related_as_retry("ls", "ls")
class TestDropContradictions:
def _read_recovery(self, failed: str, success: str) -> ExtractedPattern:
return ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content=f"File `{failed}` does not exist. The correct path is `{success}`.",
importance=0.7,
entity_refs=[success],
metadata={"error_category": "file_not_found", "failed_path": failed},
evidence_count=5,
)
def test_drops_inverse_pairs(self):
a_to_b = self._read_recovery("/x/a.rs", "/x/b.rs")
b_to_a = self._read_recovery("/x/b.rs", "/x/a.rs")
keep = self._read_recovery("/x/c.rs", "/x/d.rs")
cleaned = _drop_contradictions([a_to_b, b_to_a, keep])
assert keep in cleaned
assert a_to_b not in cleaned
assert b_to_a not in cleaned
def test_passthrough_when_no_inverse(self):
a_to_b = self._read_recovery("/x/a.rs", "/x/b.rs")
cleaned = _drop_contradictions([a_to_b])
assert cleaned == [a_to_b]
def test_only_filters_error_recovery_category(self):
env_pattern = ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="File `x` does not exist. The correct path is `y`.", # text alone
importance=0.5,
evidence_count=5,
)
cleaned = _drop_contradictions([env_pattern])
assert cleaned == [env_pattern]
def test_skips_error_recovery_with_non_canonical_content(self):
"""Bash recoveries don't match the Read regex; skip without crashing."""
bash_pattern = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="Command `foo` fails (exit_code). Use `bar` instead.",
importance=0.7,
evidence_count=5,
)
cleaned = _drop_contradictions([bash_pattern])
assert cleaned == [bash_pattern]
# =============================================================================
# Helper edge cases (branch coverage)
# =============================================================================
class TestLevenshtein:
def test_equal_returns_zero(self):
assert _levenshtein("abc", "abc") == 0
def test_empty_a_returns_len_b(self):
assert _levenshtein("", "abc") == 3
def test_empty_b_returns_len_a(self):
assert _levenshtein("abc", "") == 3
def test_swap_when_a_longer(self):
# Triggers the `len(a) > len(b)` swap branch.
assert _levenshtein("abcdef", "abc") == 3
def test_simple_substitution(self):
assert _levenshtein("kitten", "sitting") == 3
class TestBashFirstBinary:
def test_empty_returns_none(self):
assert _bash_first_binary("") is None
assert _bash_first_binary(" ") is None
def test_strips_source_venv_prefix(self):
assert _bash_first_binary("source .venv/bin/activate && pytest -x") == "pytest"
def test_skips_env_var_assignments(self):
assert _bash_first_binary("FOO=bar BAZ=qux python script.py") == "python"
def test_returns_first_token_otherwise(self):
assert _bash_first_binary("cargo test --release") == "cargo"
class TestBashBinariesMatch:
def test_equal_strings_match(self):
# Direct equality short-circuit; not exercised by the typical retry flow.
assert _bash_binaries_match("cargo", "cargo")
def test_basename_match_across_paths(self):
assert _bash_binaries_match("ruff", ".venv/bin/ruff")
assert _bash_binaries_match("/usr/bin/python3", "python3")
def test_prefix_version_match(self):
assert _bash_binaries_match("python", "python3")
def test_unrelated_binaries_do_not_match(self):
assert not _bash_binaries_match("grep", "find")
class TestPathsRelatedAsTypoEdgeCases:
def test_root_paths_rejected(self):
# After basename strip both sides become empty.
assert not _paths_related_as_typo("/", "/")
class TestCommandsRelatedAsRetrySubstantiveToken:
def test_substantive_token_beats_distance(self):
# Edit distance is too high to pass the 40% gate, but both commands
# share the substantive token "headroom-config", so the token-overlap
# path accepts the pair.
failed = "python -m foo --headroom-config=/etc/h.toml"
success = "python -m bar --headroom-config=/etc/h.toml --extra"
assert _commands_related_as_retry(failed, success)
fix(traffic-learner): tighten matchers and drop contradictions The recovery matchers paired any failed and successful tool call within a 5-call window with no semantic check that the pair was actually a retry. This produced confidently-wrong rules like: File `state.rs` does not exist. The correct path is `lib.rs`. …where the user simply read two unrelated files in the same directory. Across sessions the same user can also typo in opposite directions, producing directly contradictory rules side by side. This commit adds three structural checks: 1. Read recovery: require the failed and successful basenames to be identical or close in Levenshtein distance. Rejects the "same dir, different file" case that was the most common noise source. 2. Bash recovery: require both commands to share a binary (allowing path-prefixed variants and short prefix-versions like `python` ↔ `python3`) AND either have low normalized edit distance or share a substantive non-flag token. Rejects pairs that share only the binary name but differ in every meaningful argument. 3. Contradiction filter on flush: detect A→B and B→A pairs in error_recovery patterns and drop both. They almost always indicate opposite-direction typos in different sessions, not stable advice. Also: stash failed_path in metadata so the contradiction filter and downstream consumers can reason about pairs without parsing content. Tests: 13 new tests covering the heuristics directly. Existing tests exercising legitimate recoveries (`python`→`python3`, `ruff`→`.venv/bin/ruff`, `pip install`→success) continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:26:20 +09:00
# =============================================================================
# Traffic Learner Core Tests
# =============================================================================
class TestTrafficLearner:
@pytest.fixture
def learner(self):
"""Create a learner with low evidence threshold for testing."""
return TrafficLearner(
backend=None,
user_id="test-user",
min_evidence=1, # Save on first sighting for tests
)
@pytest.mark.asyncio
async def test_error_recovery_bash(self, learner: TrafficLearner):
"""Test error→recovery pattern extraction for Bash commands."""
# First: a failed command
await learner.on_tool_result(
tool_name="Bash",
tool_input={"command": "ruff check ."},
tool_output="zsh: command not found: ruff",
is_error=True,
)
# Then: the recovery
await learner.on_tool_result(
tool_name="Bash",
tool_input={"command": "source .venv/bin/activate && ruff check ."},
tool_output="All checks passed!",
is_error=False,
)
stats = learner.get_stats()
assert stats["patterns_extracted"] >= 1
assert stats["requests_processed"] == 2
@pytest.mark.asyncio
async def test_error_recovery_read(self, learner: TrafficLearner):
"""Test error→recovery for Read tool (wrong path → correct path)."""
await learner.on_tool_result(
tool_name="Read",
tool_input={"file_path": "/src/old_module.py"},
tool_output="No such file or directory: /src/old_module.py",
is_error=True,
)
await learner.on_tool_result(
tool_name="Read",
tool_input={"file_path": "/src/new_module.py"},
tool_output="# Module content here\nclass Foo: pass",
is_error=False,
)
stats = learner.get_stats()
assert stats["patterns_extracted"] >= 1
@pytest.mark.asyncio
async def test_environment_venv_detection(self, learner: TrafficLearner):
"""Test detection of virtual environment activation patterns."""
await learner.on_tool_result(
tool_name="Bash",
tool_input={"command": "source /project/.venv/bin/activate && pytest"},
tool_output="5 passed in 2.1s",
is_error=False,
)
stats = learner.get_stats()
assert stats["patterns_extracted"] >= 1
@pytest.mark.asyncio
async def test_preference_extraction(self, learner: TrafficLearner):
"""Test extraction of user preference signals."""
2026-03-24 15:54:12 -07:00
await learner.on_messages(
[
{"role": "user", "content": "don't use git push, I'll push manually"},
]
)
stats = learner.get_stats()
assert stats["patterns_extracted"] >= 1
@pytest.mark.asyncio
async def test_preference_from_content_blocks(self, learner: TrafficLearner):
"""Test preference extraction from Anthropic content block format."""
2026-03-24 15:54:12 -07:00
await learner.on_messages(
[
{
"role": "user",
"content": [
{"type": "text", "text": "stop running the full test suite without asking"},
],
},
]
)
stats = learner.get_stats()
assert stats["patterns_extracted"] >= 1
@pytest.mark.asyncio
async def test_evidence_accumulation(self):
"""Test that patterns need min_evidence before saving."""
learner = TrafficLearner(backend=None, min_evidence=3)
# Same error→recovery pattern 3 times
for _ in range(3):
await learner.on_tool_result(
tool_name="Bash",
tool_input={"command": "python test.py"},
tool_output="command not found: python",
is_error=True,
)
await learner.on_tool_result(
tool_name="Bash",
tool_input={"command": "python3 test.py"},
tool_output="OK",
is_error=False,
)
stats = learner.get_stats()
assert stats["patterns_extracted"] >= 3
fix(memory): bound the TrafficLearner pending-pattern accumulator (memory leak) (#2579) ## Description `TrafficLearner` (the memory/learning subsystem that accumulates patterns from proxy traffic) has an unbounded in-memory accumulator. `_pattern_counts` maps `content_hash -> (pattern, count)`. A pattern is added on first sighting, its count is bumped on each re-sighting, and it is **removed only when it reaches `min_evidence`** (default 5), at which point it is promoted and its hash moves to `_saved_hashes`: ```python if h in self._pattern_counts: existing, count = self._pattern_counts[h] count += 1 self._pattern_counts[h] = (existing, count) else: self._pattern_counts[h] = (pattern, 1) return # first sighting — wait for more evidence ... if count >= self._min_evidence: del self._pattern_counts[h] # only removal path self._saved_hashes.add(h) if len(self._saved_hashes) > self._dedup_window: # sibling IS trimmed self._saved_hashes.pop() ``` A pattern seen **once but never corroborated** — the common case for one-off traffic (a unique error string, an ad-hoc shell command, a distinct file path) — never reaches `min_evidence`, so it is **never removed**. Over a long-lived proxy processing varied traffic, `_pattern_counts` grows without bound and RSS climbs. The sibling `_saved_hashes` is explicitly trimmed to `dedup_window` ("prevent unbounded growth"); `_pattern_counts` was missed. Reproduced directly: feeding 500 distinct one-off patterns leaves 500 entries in `_pattern_counts` (one per pattern, forever). ## Fix Make `_pattern_counts` an LRU-ordered `OrderedDict` capped at a new `max_pending_patterns` (default 2048): - On each corroboration, `move_to_end(h)` so an actively-accumulating pattern stays "fresh" and is never evicted before it can be promoted. - On a first sighting when the accumulator is full, evict the least-recently-corroborated pending entry (`popitem(last=False)`). Evicting a stale one-off is safe: if it recurs it simply restarts accumulation (delayed promotion at worst) — the same tradeoff `_saved_hashes` already makes. Promotion at `min_evidence` is unchanged, and the cap (2048) is generous enough that any pattern receiving repeat sightings within a normal window reaches `min_evidence=5` long before eviction. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring ## Changes Made - `headroom/memory/traffic_learner.py`: `_pattern_counts` becomes a capped LRU `OrderedDict`; add `max_pending_patterns` (default 2048); `move_to_end` on corroboration and evict-oldest on overflow. - `tests/test_memory/test_traffic_learner.py`: a regression that 500 one-off patterns keep the accumulator at its cap, and one that a corroborated pattern still promotes into `_saved_hashes` (both sync via `asyncio.run` so they run without the pytest-asyncio plugin). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_memory/test_traffic_learner.py -q 35 failed, 109 passed # the 35 failures are pre-existing @pytest.mark.asyncio tests that need # pytest-asyncio (not configured in this environment); they fail identically # on clean main (35 failed, 107 passed) and pass in CI. My two new tests are # synchronous and pass; they add +2 passing with no new failures. # with the fix reverted, test_pending_accumulator_is_bounded fails # (the accumulator holds all 500 one-off patterns) $ uvx ruff@0.15.17 check headroom/memory/traffic_learner.py tests/test_memory/test_traffic_learner.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/memory/traffic_learner.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: built a `TrafficLearner(backend=None, min_evidence=5, max_pending_patterns=8)` and drove `_accumulate` with 500 distinct one-off `ExtractedPattern`s; separately corroborated one pattern to `min_evidence`; then reverted the source and re-ran. - Observed result: with the fix `len(_pattern_counts)` stays at the cap (8) after 500 one-offs, the corroborated pattern is removed from pending and present in `_saved_hashes`, and an actively-bumped pattern survives LRU eviction; with the fix reverted the accumulator holds all 500 one-off entries (the unbounded leak). Ran against the actual module. - Not tested: a live multi-day proxy run measuring RSS (the leak is inferred from the removed unbounded-growth path; the accumulator bound is verified directly). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable
2026-08-07 07:51:59 +05:30
def test_pending_accumulator_is_bounded(self):
"""One-off patterns that never reach ``min_evidence`` must not grow the
pending ``_pattern_counts`` accumulator without bound the sibling
``_saved_hashes`` is already trimmed to ``dedup_window`` and this one was
missed, so a long-lived proxy leaked memory across varied traffic. It is
now LRU-capped at ``max_pending_patterns``.
Sync test (drives the async accumulate via ``asyncio.run``) so it runs
without the pytest-asyncio plugin.
"""
import asyncio
learner = TrafficLearner(backend=None, min_evidence=5, max_pending_patterns=8)
async def feed_one_offs() -> None:
for i in range(500):
await learner._accumulate(
ExtractedPattern(
category=PatternCategory.PREFERENCE,
content=f"one-off pattern number {i}",
importance=0.5,
)
)
asyncio.run(feed_one_offs())
assert len(learner._pattern_counts) <= 8 # capped, not 500
def test_pending_accumulator_lru_still_promotes_corroborated_pattern(self):
"""Capping the accumulator must not break promotion: a pattern
corroborated to ``min_evidence`` without interruption is still removed
from pending and recorded in ``_saved_hashes``."""
import asyncio
learner = TrafficLearner(backend=None, min_evidence=3, max_pending_patterns=100)
pattern = ExtractedPattern(
category=PatternCategory.PREFERENCE,
content="corroborated preference",
importance=0.5,
)
async def corroborate() -> None:
await learner._accumulate(pattern) # count 1
await learner._accumulate(pattern) # count 2
assert pattern.content_hash in learner._pattern_counts
await learner._accumulate(pattern) # count 3 == min_evidence -> promote
asyncio.run(corroborate())
assert pattern.content_hash not in learner._pattern_counts # removed on promotion
assert pattern.content_hash in learner._saved_hashes
@pytest.mark.asyncio
async def test_dedup(self, learner: TrafficLearner):
"""Test that identical patterns are deduplicated."""
# Same pattern twice
for _ in range(2):
await learner.on_tool_result(
tool_name="Bash",
tool_input={"command": "ruff check ."},
tool_output="command not found: ruff",
is_error=True,
)
await learner.on_tool_result(
tool_name="Bash",
tool_input={"command": ".venv/bin/ruff check ."},
tool_output="OK",
is_error=False,
)
# Should not double-count the same pattern
stats = learner.get_stats()
# First extraction saves, second is deduped
assert stats["patterns_extracted"] >= 1
@pytest.mark.asyncio
async def test_extract_tool_results_from_messages(self, learner: TrafficLearner):
"""Test extraction of tool results from Anthropic message format."""
messages = [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "tu_1",
"name": "Bash",
"input": {"command": "ls"},
}
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "tu_1",
"content": [{"type": "text", "text": "file1.py\nfile2.py"}],
}
],
},
]
results = learner.extract_tool_results_from_messages(messages)
assert len(results) == 1
assert results[0]["tool_name"] == "Bash"
assert "file1.py" in results[0]["output"]
assert not results[0]["is_error"]
fix(proxy/openai): feed chat/completions traffic into the traffic learner (#2333) ## Description Addresses the chat/completions portion of #2060. The live traffic learner is wired into the Anthropic `/v1/messages` handler and, since then, the OpenAI Responses HTTP handler (`_observe_openai_responses_traffic`, called from `handle_openai_responses`). But `handle_openai_chat` has **no** ingestion call site: ```text headroom/proxy/handlers/openai.py handle_openai_responses -> _observe_openai_responses_traffic (wired) handle_openai_chat -> (no traffic_learner call) (gap) ``` So OpenAI-compatible clients that route through `/v1/chat/completions` — GitHub Copilot CLI, opencode, OpenAI SDKs — run through an apparently healthy proxy with Learn enabled while producing no learned patterns: the learner starts, but it never receives their tool results or user messages. ## Fix Observe the original client payload (before memory/compression mutates it) at the top of `handle_openai_chat`, mirroring the Responses and Anthropic ingestion paths: ```python await self._observe_openai_chat_traffic(original_client_messages, request_id=request_id) ``` `_observe_openai_chat_traffic` is the chat counterpart of `_observe_openai_responses_traffic`: same lazy backend wiring, same `on_tool_result` / `on_messages` lifecycle, same fail-soft `try/except`. The one format-specific piece is tool-result extraction. chat/completions encodes tool calls differently from Anthropic — the call is on an assistant message's `tool_calls` array (`id` -> function `name` + `arguments`) and each result is a separate `role: "tool"` message keyed by `tool_call_id`, so the existing `extract_tool_results_from_messages` (which scans for Anthropic `tool_use`/`tool_result` blocks) finds nothing. A new `TrafficLearner.extract_tool_results_from_openai_messages`: - builds the `tool_call_id -> function` map from assistant `tool_calls`; - for each `role: "tool"` message, resolves the tool name and joins string-or-list content; - parses the OpenAI `arguments` JSON string into a dict, so the downstream environment/recovery extractors (which call `input.get("command")`, `input.get("file_path")`, ...) see the same shape as an Anthropic `tool_use.input` instead of a raw string; - sniffs `is_error` from the output (chat tool messages carry no error flag). It returns the same `{tool_name, input, output, is_error}` shape as the Anthropic extractor, so `on_tool_result` stays format-agnostic. User-message preference extraction (`on_messages`) already reads plain `role`/`content`, so it consumes chat messages unchanged. Scope: this wires the **chat/completions** path. Codex WebSocket ingestion (`handle_openai_responses_ws`) additionally needs per-`response.create` evaluation plus transcript-replay baselining on reconnect, so it is intentionally left as a follow-up rather than half-implemented here. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/memory/traffic_learner.py`: add `extract_tool_results_from_openai_messages` (OpenAI chat tool-result extraction with `arguments` JSON parsed to a dict). - `headroom/proxy/handlers/openai.py`: add `_observe_openai_chat_traffic` and call it from `handle_openai_chat` on the original client payload. - `tests/test_memory/test_traffic_learner.py`: cover the OpenAI extractor (name resolution, arguments parsing, list content, error sniff, malformed/orphan handling, empty case). - `CHANGELOG.md`: Bug Fixes entry. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uvx ruff@0.15.17 check headroom/memory/traffic_learner.py headroom/proxy/handlers/openai.py tests/test_memory/test_traffic_learner.py All checks passed! $ uvx ruff@0.15.17 format --check <same files> 3 files already formatted $ uvx mypy@1.20.2 --ignore-missing-imports headroom/memory/traffic_learner.py # clean for this file (the one reported error is a pre-existing # headroom/_subprocess.py:18 no-any-return, unrelated to this change and # present on main with these edits stashed) ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx mypy@1.20.2`. A full `pytest` OOMs this box (ML-stack import), so I reproduced the extractor with a dependency-free script and left the full pytest to CI. - Exact command / steps: replicated `extract_tool_results_from_openai_messages` and ran it over a typical chat round-trip (assistant `tool_calls` for `bash` + `read_file`, then two `role: "tool"` results, one erroring and one with list content), plus malformed-`arguments`, orphan-`tool_call_id`, and no-tool cases. - Observed result: tool names resolved from the call-id map; `arguments` parsed to a dict so `input.get("command")` works; list content joined; `is_error` sniffed from output; malformed arguments degrade to `{}` and an orphan id yields `unknown` without raising. The added unit tests assert the same through a real `TrafficLearner`. - Not tested: a live Copilot CLI session end to end; the added tests drive `TrafficLearner.extract_tool_results_from_openai_messages` directly, matching the existing `test_extract_tool_results_from_messages` pattern. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The "unit tests pass locally" box is unchecked because a local pytest run imports the ML stack and OOMs this box; the added tests reuse the existing `TrafficLearner(backend=None, ...)` harness in `test_traffic_learner.py` (no real backend) and run under the normal CI pytest job, and the extractor behavior is corroborated by the standalone proof above. This PR is deliberately scoped to `/v1/chat/completions`; I'm happy to follow up with the Codex WebSocket ingestion path (which needs the transcript-replay baselining discussed in the issue) as a separate change if useful. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-18 22:39:01 +05:30
def test_extract_tool_results_from_openai_messages(self, learner: TrafficLearner):
"""OpenAI chat/completions tool results: assistant tool_calls + role:tool.
Regression for the chat-path portion of #2060 — the extractor must
resolve the tool name from the assistant ``tool_calls`` id map, parse the
``arguments`` JSON string into a dict (so downstream ``input.get(...)``
works), join list content, and sniff errors from the output.
"""
messages = [
{"role": "user", "content": "run the tests"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "bash", "arguments": '{"command": "pytest -q"}'},
},
{
"id": "call_2",
"type": "function",
"function": {"name": "read_file", "arguments": '{"file_path": "/a/b.py"}'},
},
],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": "Traceback (most recent call last):\nModuleNotFoundError: No module named x",
},
{
"role": "tool",
"tool_call_id": "call_2",
"content": [{"type": "text", "text": "file body"}],
},
]
results = learner.extract_tool_results_from_openai_messages(messages)
assert len(results) == 2
by_name = {r["tool_name"]: r for r in results}
assert by_name["bash"]["input"] == {"command": "pytest -q"} # parsed to dict
assert by_name["bash"]["input"].get("command") == "pytest -q" # downstream .get works
assert by_name["bash"]["is_error"] is True
assert by_name["read_file"]["input"] == {"file_path": "/a/b.py"}
assert by_name["read_file"]["output"] == "file body" # list content joined
assert by_name["read_file"]["is_error"] is False
def test_extract_openai_tool_results_handles_malformed_and_orphans(
self, learner: TrafficLearner
):
"""Malformed arguments become an empty dict; an unmatched tool_call_id
yields ``unknown`` neither raises, so on_tool_result stays safe."""
messages = [
{
"role": "assistant",
"tool_calls": [{"id": "c1", "function": {"name": "grep", "arguments": "not json"}}],
},
{"role": "tool", "tool_call_id": "c1", "content": "ok"},
{"role": "tool", "tool_call_id": "missing", "content": "orphan"},
]
results = learner.extract_tool_results_from_openai_messages(messages)
assert results[0]["tool_name"] == "grep"
assert results[0]["input"] == {}
assert results[1]["tool_name"] == "unknown"
assert results[1]["input"] == {}
def test_extract_openai_tool_results_empty_without_tool_messages(self, learner: TrafficLearner):
assert (
learner.extract_tool_results_from_openai_messages([{"role": "user", "content": "hi"}])
== []
)
@pytest.mark.asyncio
async def test_tool_history_bounded(self, learner: TrafficLearner):
"""Test that tool history stays within max_history."""
for i in range(30):
await learner.on_tool_result(
tool_name="Read",
tool_input={"file_path": f"/file{i}.py"},
tool_output=f"content {i}",
is_error=False,
)
assert len(learner._tool_history) <= learner._max_history
@pytest.mark.asyncio
async def test_no_pattern_from_success_only(self, learner: TrafficLearner):
"""Test that success without prior error doesn't generate error_recovery pattern."""
await learner.on_tool_result(
tool_name="Bash",
tool_input={"command": "echo hello"},
tool_output="hello",
is_error=False,
)
stats = learner.get_stats()
# Only environment patterns possible, no error_recovery
assert stats["requests_processed"] == 1
# =============================================================================
# Pattern Model Tests
# =============================================================================
class TestExtractedPattern:
def test_content_hash_deterministic(self):
p1 = ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="Use venv",
importance=0.5,
)
p2 = ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="Use venv",
importance=0.8, # Different importance, same hash
)
assert p1.content_hash == p2.content_hash
def test_different_content_different_hash(self):
p1 = ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="Use venv",
importance=0.5,
)
p2 = ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="Use conda",
importance=0.5,
)
assert p1.content_hash != p2.content_hash
feat(learn): live flush of traffic patterns to agent-native context files Replaces the previous shutdown-only flush with a debounced, near-real-time dirty-flag flush worker that writes patterns into the correct CLAUDE.md / MEMORY.md bucket as traffic accumulates. - New FLUSH_DEBOUNCE_SECONDS gate (10s) prevents context-file thrash on bursty traffic while keeping updates "live" from the user's perspective. - TrafficLearner.start() now spawns a _flush_worker alongside the save worker; _accumulate() sets a dirty flag; _flush_worker() calls flush_to_file() when dirty and past the debounce window. - flush_to_file() now reads *both* persisted rows (memory.db) and the in-memory accumulator via _load_persisted_patterns_from_sqlite and _collect_all_patterns, so patterns survive proxy restarts and the agent-native files converge toward the full learned set. - Patterns are bucketed per-project via the learn plugin registry (plugin.discover_projects()) and anchored to project roots through longest-matching-path on content or entity_refs (_project_for_pattern). Un-anchored patterns are dropped. - Patterns are routed by PatternCategory to either CONTEXT_FILE (CLAUDE.md) or MEMORY_FILE (MEMORY.md) via _patterns_to_recommendations + _CATEGORY_TO_TARGET. - Live flushes require evidence_count >= 2; shutdown flushes accept single-evidence rows to avoid losing last-session signal. Adds tests for project routing, persisted-pattern loading, category routing, and the debounced flush worker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:00:15 +02:00
# =============================================================================
# Project Routing
# =============================================================================
class TestProjectForPattern:
def _project(self, path: str):
from pathlib import Path as _P
from headroom.learn.models import ProjectInfo
p = _P(path)
return ProjectInfo(name=p.name, project_path=p, data_path=p)
def test_matches_longest_root(self):
proj_a = self._project("/x/a")
proj_b = self._project("/x/a/b")
pattern = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="File `/x/a/b/foo.py` does not exist.",
importance=0.5,
)
result = _project_for_pattern(pattern, [proj_a, proj_b])
assert result is proj_b
def test_returns_none_for_unanchored(self):
proj_a = self._project("/x/a")
pattern = ExtractedPattern(
category=PatternCategory.PREFERENCE,
content="User preference: use terse responses",
importance=0.7,
)
assert _project_for_pattern(pattern, [proj_a]) is None
def test_matches_via_entity_refs(self):
proj = self._project("/x/a")
pattern = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="Command failed.",
importance=0.5,
entity_refs=["/x/a/tool.py"],
)
assert _project_for_pattern(pattern, [proj]) is proj
def test_windows_root_with_trailing_backslash_matches_child_path(self):
proj = self._project(r"C:\Users\john.doe\repo\\")
pattern = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content=r"File `C:\Users\john.doe\repo\src\main.py` does not exist.",
importance=0.5,
)
assert _project_for_pattern(pattern, [proj]) is proj
feat(learn): live flush of traffic patterns to agent-native context files Replaces the previous shutdown-only flush with a debounced, near-real-time dirty-flag flush worker that writes patterns into the correct CLAUDE.md / MEMORY.md bucket as traffic accumulates. - New FLUSH_DEBOUNCE_SECONDS gate (10s) prevents context-file thrash on bursty traffic while keeping updates "live" from the user's perspective. - TrafficLearner.start() now spawns a _flush_worker alongside the save worker; _accumulate() sets a dirty flag; _flush_worker() calls flush_to_file() when dirty and past the debounce window. - flush_to_file() now reads *both* persisted rows (memory.db) and the in-memory accumulator via _load_persisted_patterns_from_sqlite and _collect_all_patterns, so patterns survive proxy restarts and the agent-native files converge toward the full learned set. - Patterns are bucketed per-project via the learn plugin registry (plugin.discover_projects()) and anchored to project roots through longest-matching-path on content or entity_refs (_project_for_pattern). Un-anchored patterns are dropped. - Patterns are routed by PatternCategory to either CONTEXT_FILE (CLAUDE.md) or MEMORY_FILE (MEMORY.md) via _patterns_to_recommendations + _CATEGORY_TO_TARGET. - Live flushes require evidence_count >= 2; shutdown flushes accept single-evidence rows to avoid losing last-session signal. Adds tests for project routing, persisted-pattern loading, category routing, and the debounced flush worker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:00:15 +02:00
def test_no_false_match_on_prefix_boundary(self):
# /x/ab should not match a project rooted at /x/a
proj_a = self._project("/x/a")
pattern = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="File `/x/ab/foo.py` does not exist.",
importance=0.5,
)
assert _project_for_pattern(pattern, [proj_a]) is None
# =============================================================================
# Persisted-pattern loading from memory.db
# =============================================================================
class TestLoadPersistedPatterns:
def _make_db(self, tmp_path, rows: list[dict]):
import json as _json
import sqlite3 as _sql
db = tmp_path / "memory.db"
conn = _sql.connect(db)
conn.execute(
"CREATE TABLE memories ("
"id TEXT PRIMARY KEY, content TEXT NOT NULL, "
"metadata TEXT NOT NULL DEFAULT '{}', "
"entity_refs TEXT NOT NULL DEFAULT '[]', "
"importance REAL NOT NULL DEFAULT 0.5, "
"created_at TEXT)"
feat(learn): live flush of traffic patterns to agent-native context files Replaces the previous shutdown-only flush with a debounced, near-real-time dirty-flag flush worker that writes patterns into the correct CLAUDE.md / MEMORY.md bucket as traffic accumulates. - New FLUSH_DEBOUNCE_SECONDS gate (10s) prevents context-file thrash on bursty traffic while keeping updates "live" from the user's perspective. - TrafficLearner.start() now spawns a _flush_worker alongside the save worker; _accumulate() sets a dirty flag; _flush_worker() calls flush_to_file() when dirty and past the debounce window. - flush_to_file() now reads *both* persisted rows (memory.db) and the in-memory accumulator via _load_persisted_patterns_from_sqlite and _collect_all_patterns, so patterns survive proxy restarts and the agent-native files converge toward the full learned set. - Patterns are bucketed per-project via the learn plugin registry (plugin.discover_projects()) and anchored to project roots through longest-matching-path on content or entity_refs (_project_for_pattern). Un-anchored patterns are dropped. - Patterns are routed by PatternCategory to either CONTEXT_FILE (CLAUDE.md) or MEMORY_FILE (MEMORY.md) via _patterns_to_recommendations + _CATEGORY_TO_TARGET. - Live flushes require evidence_count >= 2; shutdown flushes accept single-evidence rows to avoid losing last-session signal. Adds tests for project routing, persisted-pattern loading, category routing, and the debounced flush worker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:00:15 +02:00
)
for i, r in enumerate(rows):
conn.execute(
"INSERT INTO memories "
"(id, content, metadata, entity_refs, importance, created_at) "
"VALUES (?,?,?,?,?,?)",
feat(learn): live flush of traffic patterns to agent-native context files Replaces the previous shutdown-only flush with a debounced, near-real-time dirty-flag flush worker that writes patterns into the correct CLAUDE.md / MEMORY.md bucket as traffic accumulates. - New FLUSH_DEBOUNCE_SECONDS gate (10s) prevents context-file thrash on bursty traffic while keeping updates "live" from the user's perspective. - TrafficLearner.start() now spawns a _flush_worker alongside the save worker; _accumulate() sets a dirty flag; _flush_worker() calls flush_to_file() when dirty and past the debounce window. - flush_to_file() now reads *both* persisted rows (memory.db) and the in-memory accumulator via _load_persisted_patterns_from_sqlite and _collect_all_patterns, so patterns survive proxy restarts and the agent-native files converge toward the full learned set. - Patterns are bucketed per-project via the learn plugin registry (plugin.discover_projects()) and anchored to project roots through longest-matching-path on content or entity_refs (_project_for_pattern). Un-anchored patterns are dropped. - Patterns are routed by PatternCategory to either CONTEXT_FILE (CLAUDE.md) or MEMORY_FILE (MEMORY.md) via _patterns_to_recommendations + _CATEGORY_TO_TARGET. - Live flushes require evidence_count >= 2; shutdown flushes accept single-evidence rows to avoid losing last-session signal. Adds tests for project routing, persisted-pattern loading, category routing, and the debounced flush worker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:00:15 +02:00
(
str(i),
r["content"],
_json.dumps(r.get("metadata", {})),
_json.dumps(r.get("entity_refs", [])),
r.get("importance", 0.5),
r.get("created_at"),
feat(learn): live flush of traffic patterns to agent-native context files Replaces the previous shutdown-only flush with a debounced, near-real-time dirty-flag flush worker that writes patterns into the correct CLAUDE.md / MEMORY.md bucket as traffic accumulates. - New FLUSH_DEBOUNCE_SECONDS gate (10s) prevents context-file thrash on bursty traffic while keeping updates "live" from the user's perspective. - TrafficLearner.start() now spawns a _flush_worker alongside the save worker; _accumulate() sets a dirty flag; _flush_worker() calls flush_to_file() when dirty and past the debounce window. - flush_to_file() now reads *both* persisted rows (memory.db) and the in-memory accumulator via _load_persisted_patterns_from_sqlite and _collect_all_patterns, so patterns survive proxy restarts and the agent-native files converge toward the full learned set. - Patterns are bucketed per-project via the learn plugin registry (plugin.discover_projects()) and anchored to project roots through longest-matching-path on content or entity_refs (_project_for_pattern). Un-anchored patterns are dropped. - Patterns are routed by PatternCategory to either CONTEXT_FILE (CLAUDE.md) or MEMORY_FILE (MEMORY.md) via _patterns_to_recommendations + _CATEGORY_TO_TARGET. - Live flushes require evidence_count >= 2; shutdown flushes accept single-evidence rows to avoid losing last-session signal. Adds tests for project routing, persisted-pattern loading, category routing, and the debounced flush worker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:00:15 +02:00
),
)
conn.commit()
conn.close()
return db
def test_dedupes_by_content_and_sums_evidence(self, tmp_path):
db = self._make_db(
tmp_path,
[
{
"content": "Command `foo` fails.",
"metadata": {
"source": "traffic_learner",
"category": "error_recovery",
"evidence_count": 2,
},
},
{
"content": "Command `foo` fails.",
"metadata": {
"source": "traffic_learner",
"category": "error_recovery",
"evidence_count": 3,
},
},
],
)
patterns = _load_persisted_patterns_from_sqlite(db)
assert len(patterns) == 1
assert patterns[0].evidence_count == 5
assert patterns[0].category == PatternCategory.ERROR_RECOVERY
def test_skips_non_traffic_rows(self, tmp_path):
db = self._make_db(
tmp_path,
[
{
"content": "Something else",
"metadata": {"source": "other"},
},
{
"content": "From traffic",
"metadata": {
"source": "traffic_learner",
"category": "environment",
},
},
],
)
patterns = _load_persisted_patterns_from_sqlite(db)
assert len(patterns) == 1
assert patterns[0].content == "From traffic"
def test_reads_importance_column(self, tmp_path):
db = self._make_db(
tmp_path,
[
{
"content": "High-importance pattern",
"metadata": {
"source": "traffic_learner",
"category": "environment",
},
"importance": 0.85,
},
],
)
patterns = _load_persisted_patterns_from_sqlite(db)
assert len(patterns) == 1
assert patterns[0].importance == 0.85
def test_skips_unknown_category(self, tmp_path):
db = self._make_db(
tmp_path,
[
{
"content": "X",
"metadata": {"source": "traffic_learner", "category": "bogus"},
},
],
)
assert _load_persisted_patterns_from_sqlite(db) == []
# =============================================================================
# Category → recommendation routing
# =============================================================================
class TestPatternsToRecommendations:
def test_routes_preference_to_memory_file(self):
from headroom.learn.models import RecommendationTarget
patterns = [
ExtractedPattern(
category=PatternCategory.PREFERENCE,
content="User prefers terse output",
importance=0.8,
evidence_count=3,
),
]
recs = _patterns_to_recommendations(patterns)
assert len(recs) == 1
assert recs[0].target == RecommendationTarget.MEMORY_FILE
assert "User prefers terse output" in recs[0].content
def test_routes_environment_to_context_file(self):
from headroom.learn.models import RecommendationTarget
patterns = [
ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="Use uv run python",
importance=0.7,
evidence_count=4,
),
]
recs = _patterns_to_recommendations(patterns)
assert len(recs) == 1
assert recs[0].target == RecommendationTarget.CONTEXT_FILE
def test_groups_by_category(self):
patterns = [
ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="A",
importance=0.5,
evidence_count=2,
),
ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="B",
importance=0.5,
evidence_count=5,
),
]
recs = _patterns_to_recommendations(patterns)
assert len(recs) == 1
# B has higher evidence, should sort first
lines = recs[0].content.splitlines()
assert lines[0] == "- B"
assert lines[1] == "- A"
assert recs[0].evidence_count == 7
# =============================================================================
# Debounced flush worker
# =============================================================================
class TestFlushDebounce:
@pytest.mark.asyncio
async def test_flush_worker_rate_limits(self, monkeypatch):
"""Rapid dirty flags should not cause rapid flush_to_file calls."""
from headroom.memory import traffic_learner as tl_mod
# Shorten debounce for a fast test
monkeypatch.setattr(tl_mod, "FLUSH_DEBOUNCE_SECONDS", 0.5)
learner = TrafficLearner(backend=None, min_evidence=1)
call_count = 0
async def fake_flush() -> None:
nonlocal call_count
call_count += 1
learner.flush_to_file = fake_flush # type: ignore[method-assign]
await learner.start()
# Toggle dirty rapidly over ~1.2s, which permits at most ~2 flushes.
for _ in range(30):
learner._flush_dirty = True
await __import__("asyncio").sleep(0.04)
await learner.stop()
# start() kicked a flush dirty→false at some point; stop() also calls
# flush_to_file once (final flush). We want evidence the worker did
# NOT call flush on every sleep tick — cap is generous.
assert call_count <= 5, f"Expected few flushes, got {call_count}"
assert call_count >= 1, "Expected at least one flush during the burst"
fix(learn): persist real evidence_count and bump on re-sighting Before this change, every persisted traffic_learner row in memory.db landed with evidence_count=1, causing two user-visible problems: 1. The live flush gate (evidence_count >= 2) filtered out every row, so CLAUDE.md / MEMORY.md never received the patterns the learner saw repeatedly. 2. _saved_hashes is in-memory only and reset on each proxy restart, so a pattern seen once in session A then twice in session B would insert a *duplicate* DB row instead of bumping the existing one. Users accumulated many rows stuck at 1 instead of a few rows with high evidence. Root cause chain: - _accumulate tracks a running count in the _pattern_counts tuple but enqueues the ExtractedPattern dataclass with its default evidence_count=1 intact. - _save_worker writes pattern.evidence_count into metadata verbatim. - After save, the hash goes into _saved_hashes and further sightings are early-returned — never bumped. - Next process start has empty _saved_hashes, so the same content goes through the accumulator as fresh and gets re-saved. Fix: - _accumulate now sets pattern.evidence_count = count before enqueuing, so DB rows reflect the real number of sightings at save time. - _save_worker captures the Memory.id returned by save_memory and records content_hash → id in a new _persisted_ids map. - _accumulate's saved-hash branch now awaits _bump_persisted_evidence(memory_id), which runs an atomic json_set('$.evidence_count', existing + 1) UPDATE via asyncio.to_thread to keep the proxy hot path non-blocking. - start() calls a new _hydrate_persisted_state() that reads existing traffic_learner rows' (id, content) pairs from the DB and pre-seeds _saved_hashes + _persisted_ids. Cross-session re-sightings bump the seeded row instead of inserting a duplicate. - _load_persisted_patterns_from_sqlite and _hydrate_persisted_state query by json_extract(metadata, '$.source') = 'traffic_learner' instead of the prior LIKE on raw JSON — the bump path uses json_set, which rewrites the metadata string without the default ": " spacing, which would otherwise make the LIKE blind to bumped rows. Adds TestEvidencePersistence with three cases: - save persists the actual accumulated count (not the default 1) - re-sightings bump the persisted row instead of creating duplicates - a fresh learner hydrates _saved_hashes from DB, so cross-session re-sightings bump the pre-existing row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:02:35 +02:00
# =============================================================================
# Evidence-count persistence & re-sighting bumps
# =============================================================================
class _FakeBackend:
"""Minimal LocalBackend stand-in that persists to a real SQLite file.
Provides just enough surface area for TrafficLearner: `_config.db_path`
(read by `_resolve_backend_db_path`) and an `async save_memory` that
inserts a row and returns an object with `.id`.
"""
def __init__(self, db_path):
import types as _types
self._config = _types.SimpleNamespace(db_path=str(db_path))
self._db_path = str(db_path)
fix(memory): keep vector metadata in sync (#2295) ## Description Fixes #2296. Metadata-only memory updates can leave the primary store, vector-index metadata, and cache inconsistent. TrafficLearner also performs an atomic SQLite evidence increment that bypasses normal secondary-index refresh. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Refresh vector metadata for HierarchicalMemory metadata-only, importance, and entity-reference updates. - Add a LocalBackend path that reloads a memory from the primary store and refreshes vector metadata plus cache state. - Preserve the atomic TrafficLearner SQL evidence increment, then refresh secondary state only when a row was updated. - Keep refresh failures fail-open and distinguish them from primary-store increment failures in logs. - Add backend-neutral contract tests instead of inspecting a specific vector adapter private field. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) — verified locally: mypy 1.20.2, no issues in 504 source files - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text 141 passed, 1 skipped ruff check: passed ruff format --check: passed ``` The first CI run exposed one backend-specific test assertion against HNSW private state while CI used SQLiteVectorIndex. Commit a1aff399 removes that assertion and keeps the backend-neutral mock contract test. ## Real Behavior Proof - Environment: macOS, Python 3.13, current Headroom main. - Exact command / steps: update a Memory with metadata only through HierarchicalMemory, assert the vector index receives the updated Memory, then perform a TrafficLearner evidence bump and assert LocalBackend refresh is called only for an existing row. - Observed result: metadata-only update refreshes vector metadata without re-embedding content; evidence bump remains atomic and refreshes vector/cache state; an unknown memory ID triggers no refresh. - Not tested: remote memory backends, full repository suite, or live multi-process writers against one SQLite database. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project style guidelines - [x] I have performed a self-review of my code - [x] I have commented code where the behavior is not self-explanatory - [x] I have made corresponding documentation changes (N/A — internal bug fix, no user-facing docs/changelog impact) - [x] My changes generate no new warnings - [x] I have added tests that prove the fix is effective - [x] New and existing focused unit tests pass locally - [x] I have updated CHANGELOG.md if applicable (N/A — internal bug fix, no user-facing docs/changelog impact) ## Screenshots (if applicable) Not applicable. ## Additional Notes Draft for storage-owner feedback on the refresh API and write overhead. The refresh reuses the existing embedding and does not invoke the embedder.
2026-08-12 12:46:13 +08:00
self.refreshed_ids = []
async def refresh_memory_indexes(self, memory_id: str):
self.refreshed_ids.append(memory_id)
fix(learn): persist real evidence_count and bump on re-sighting Before this change, every persisted traffic_learner row in memory.db landed with evidence_count=1, causing two user-visible problems: 1. The live flush gate (evidence_count >= 2) filtered out every row, so CLAUDE.md / MEMORY.md never received the patterns the learner saw repeatedly. 2. _saved_hashes is in-memory only and reset on each proxy restart, so a pattern seen once in session A then twice in session B would insert a *duplicate* DB row instead of bumping the existing one. Users accumulated many rows stuck at 1 instead of a few rows with high evidence. Root cause chain: - _accumulate tracks a running count in the _pattern_counts tuple but enqueues the ExtractedPattern dataclass with its default evidence_count=1 intact. - _save_worker writes pattern.evidence_count into metadata verbatim. - After save, the hash goes into _saved_hashes and further sightings are early-returned — never bumped. - Next process start has empty _saved_hashes, so the same content goes through the accumulator as fresh and gets re-saved. Fix: - _accumulate now sets pattern.evidence_count = count before enqueuing, so DB rows reflect the real number of sightings at save time. - _save_worker captures the Memory.id returned by save_memory and records content_hash → id in a new _persisted_ids map. - _accumulate's saved-hash branch now awaits _bump_persisted_evidence(memory_id), which runs an atomic json_set('$.evidence_count', existing + 1) UPDATE via asyncio.to_thread to keep the proxy hot path non-blocking. - start() calls a new _hydrate_persisted_state() that reads existing traffic_learner rows' (id, content) pairs from the DB and pre-seeds _saved_hashes + _persisted_ids. Cross-session re-sightings bump the seeded row instead of inserting a duplicate. - _load_persisted_patterns_from_sqlite and _hydrate_persisted_state query by json_extract(metadata, '$.source') = 'traffic_learner' instead of the prior LIKE on raw JSON — the bump path uses json_set, which rewrites the metadata string without the default ": " spacing, which would otherwise make the LIKE blind to bumped rows. Adds TestEvidencePersistence with three cases: - save persists the actual accumulated count (not the default 1) - re-sightings bump the persisted row instead of creating duplicates - a fresh learner hydrates _saved_hashes from DB, so cross-session re-sightings bump the pre-existing row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:02:35 +02:00
async def save_memory(
self,
*,
content: str,
user_id: str,
importance: float,
metadata: dict,
):
import json as _json
import sqlite3 as _sql
import types as _types
import uuid
mid = str(uuid.uuid4())
conn = _sql.connect(self._db_path)
try:
conn.execute(
"INSERT INTO memories (id, content, metadata, entity_refs, importance) "
"VALUES (?,?,?,?,?)",
(mid, content, _json.dumps(metadata), "[]", importance),
)
conn.commit()
finally:
conn.close()
return _types.SimpleNamespace(id=mid)
def _init_db(path):
import sqlite3 as _sql
conn = _sql.connect(path)
conn.execute(
"CREATE TABLE memories ("
"id TEXT PRIMARY KEY, content TEXT NOT NULL, "
"metadata TEXT NOT NULL DEFAULT '{}', "
"entity_refs TEXT NOT NULL DEFAULT '[]', "
"importance REAL NOT NULL DEFAULT 0.5, "
"created_at TEXT)"
fix(learn): persist real evidence_count and bump on re-sighting Before this change, every persisted traffic_learner row in memory.db landed with evidence_count=1, causing two user-visible problems: 1. The live flush gate (evidence_count >= 2) filtered out every row, so CLAUDE.md / MEMORY.md never received the patterns the learner saw repeatedly. 2. _saved_hashes is in-memory only and reset on each proxy restart, so a pattern seen once in session A then twice in session B would insert a *duplicate* DB row instead of bumping the existing one. Users accumulated many rows stuck at 1 instead of a few rows with high evidence. Root cause chain: - _accumulate tracks a running count in the _pattern_counts tuple but enqueues the ExtractedPattern dataclass with its default evidence_count=1 intact. - _save_worker writes pattern.evidence_count into metadata verbatim. - After save, the hash goes into _saved_hashes and further sightings are early-returned — never bumped. - Next process start has empty _saved_hashes, so the same content goes through the accumulator as fresh and gets re-saved. Fix: - _accumulate now sets pattern.evidence_count = count before enqueuing, so DB rows reflect the real number of sightings at save time. - _save_worker captures the Memory.id returned by save_memory and records content_hash → id in a new _persisted_ids map. - _accumulate's saved-hash branch now awaits _bump_persisted_evidence(memory_id), which runs an atomic json_set('$.evidence_count', existing + 1) UPDATE via asyncio.to_thread to keep the proxy hot path non-blocking. - start() calls a new _hydrate_persisted_state() that reads existing traffic_learner rows' (id, content) pairs from the DB and pre-seeds _saved_hashes + _persisted_ids. Cross-session re-sightings bump the seeded row instead of inserting a duplicate. - _load_persisted_patterns_from_sqlite and _hydrate_persisted_state query by json_extract(metadata, '$.source') = 'traffic_learner' instead of the prior LIKE on raw JSON — the bump path uses json_set, which rewrites the metadata string without the default ": " spacing, which would otherwise make the LIKE blind to bumped rows. Adds TestEvidencePersistence with three cases: - save persists the actual accumulated count (not the default 1) - re-sightings bump the persisted row instead of creating duplicates - a fresh learner hydrates _saved_hashes from DB, so cross-session re-sightings bump the pre-existing row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:02:35 +02:00
)
conn.commit()
conn.close()
def _read_traffic_rows(db_path):
import json as _json
import sqlite3 as _sql
conn = _sql.connect(db_path)
try:
rows = conn.execute(
"SELECT id, content, metadata FROM memories "
"WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
).fetchall()
finally:
conn.close()
return [(r[0], r[1], _json.loads(r[2])) for r in rows]
async def _wait_for_saved(learner: TrafficLearner, count: int, db_path) -> None:
"""Wait until at least `count` traffic_learner rows exist in the DB."""
import asyncio as _asyncio
for _ in range(100):
if len(_read_traffic_rows(db_path)) >= count:
return
await _asyncio.sleep(0.02)
raise AssertionError(
f"Timeout waiting for {count} saved row(s); got {len(_read_traffic_rows(db_path))}"
)
class TestEvidencePersistence:
@pytest.mark.asyncio
async def test_save_persists_actual_evidence_count(self, tmp_path):
"""The count written to the DB reflects total sightings, not the default 1."""
db = tmp_path / "memory.db"
_init_db(db)
backend = _FakeBackend(db)
learner = TrafficLearner(backend=backend, min_evidence=3)
await learner.start()
pattern_kwargs = {
"category": PatternCategory.ENVIRONMENT,
"content": "Use /usr/bin/python3 for system scripts.",
"importance": 0.6,
}
for _ in range(3):
await learner._accumulate(ExtractedPattern(**pattern_kwargs))
await _wait_for_saved(learner, 1, db)
await learner.stop()
rows = _read_traffic_rows(db)
assert len(rows) == 1
assert rows[0][2]["evidence_count"] == 3
@pytest.mark.asyncio
async def test_resighting_bumps_persisted_row(self, tmp_path):
"""Sightings after save bump the existing row instead of creating duplicates."""
db = tmp_path / "memory.db"
_init_db(db)
backend = _FakeBackend(db)
learner = TrafficLearner(backend=backend, min_evidence=2)
await learner.start()
def mk() -> ExtractedPattern:
return ExtractedPattern(
category=PatternCategory.PREFERENCE,
content="User preference: terse replies.",
importance=0.7,
)
# Two sightings → save with evidence_count=2.
await learner._accumulate(mk())
await learner._accumulate(mk())
await _wait_for_saved(learner, 1, db)
# Three more sightings → three bumps.
for _ in range(3):
await learner._accumulate(mk())
await learner.stop()
rows = _read_traffic_rows(db)
assert len(rows) == 1, "re-sightings must not create duplicate rows"
assert rows[0][2]["evidence_count"] == 5
@pytest.mark.asyncio
async def test_hydrate_prevents_cross_session_duplicates(self, tmp_path):
"""A second session re-sighting an already-persisted pattern bumps, doesn't insert."""
import json as _json
import sqlite3 as _sql
db = tmp_path / "memory.db"
_init_db(db)
# Session 1 row pre-seeded directly.
seeded_content = "Command `foo` fails; use `bar` instead."
conn = _sql.connect(db)
conn.execute(
"INSERT INTO memories (id, content, metadata, entity_refs, importance) "
"VALUES (?,?,?,?,?)",
(
"seed-id",
seeded_content,
_json.dumps(
{
"source": "traffic_learner",
"category": "error_recovery",
"evidence_count": 2,
}
),
"[]",
0.7,
),
)
conn.commit()
conn.close()
# Session 2: fresh learner, hydrates from DB on start().
backend = _FakeBackend(db)
learner = TrafficLearner(backend=backend, min_evidence=2)
await learner.start()
def mk() -> ExtractedPattern:
return ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content=seeded_content,
importance=0.7,
)
# Two sightings: both should bump the seeded row (no duplicates).
await learner._accumulate(mk())
await learner._accumulate(mk())
await learner.stop()
rows = _read_traffic_rows(db)
assert len(rows) == 1
assert rows[0][0] == "seed-id"
assert rows[0][2]["evidence_count"] == 4
# =============================================================================
# flush_to_file end-to-end + early-return paths
# =============================================================================
class _FakeWriteResult:
def __init__(self, files_written):
self.files_written = files_written
class _FakeWriter:
def __init__(self):
self.calls: list[tuple] = []
self.files_to_return: list = []
self.raise_on_write = False
def write(self, recommendations, project, *, dry_run):
self.calls.append((list(recommendations), project, dry_run))
if self.raise_on_write:
raise RuntimeError("boom")
return _FakeWriteResult(list(self.files_to_return))
class _FakePlugin:
def __init__(self, roots, writer, discover_raises=False):
self._roots = roots
self._writer = writer
self._discover_raises = discover_raises
def discover_projects(self):
if self._discover_raises:
raise RuntimeError("discover blew up")
return list(self._roots)
def create_writer(self):
return self._writer
def _install_plugin_registry(monkeypatch, plugin):
"""Stub out headroom.learn.registry so flush_to_file uses our fake."""
import sys
import types as _types
fake = _types.ModuleType("headroom.learn.registry")
fake.auto_detect_plugins = lambda: [plugin] if plugin is not None else [] # type: ignore[attr-defined]
fake.get_plugin = lambda agent_type: plugin # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "headroom.learn.registry", fake)
feat: headroom wrap opencode / unwrap opencode CLI (#1105) ## Summary This PR implements transparent `headroom wrap opencode` support without asking users to edit OpenCode provider URLs, choose an extra CLI flag, or maintain a static provider list. The wrapper now lives at the runtime transport boundary: OpenCode keeps its user/provider config, while Headroom intercepts outbound provider traffic in-process and routes it through the local Headroom proxy. ## What changed ### Transparent OpenCode wrapping - `headroom wrap opencode` injects the `headroom-opencode` plugin through `OPENCODE_CONFIG_CONTENT`. - Existing OpenCode provider URLs are preserved. We do not rewrite user config URLs to point at Headroom. - Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are preserved. - Local OpenCode traffic, localhost traffic, and Headroom proxy traffic bypass the shim to avoid loops. ### Runtime transport interception - Added an OpenCode plugin transport shim that wraps: - `globalThis.fetch` - `http.request` / `http.get` - `https.request` / `https.get` - External provider calls are routed to the local Headroom proxy. - The original upstream origin is passed through `x-headroom-base-url`, so the proxy can forward to the real provider without changing OpenCode config. - External `http2.connect` is blocked loudly instead of allowing direct provider traffic to leak outside Headroom. ### Live provider additions Provider coverage is no longer based on a static config scan. Because routing happens at outbound request time, providers added mid-session are routed through Headroom automatically as long as they use the covered Node transport paths. ### Subagent and child-process coverage - The parent OpenCode plugin sets a packaged Node preload shim through `NODE_OPTIONS=--import=.../hook-shim/handler.js`. - The transport shim patches `child_process.spawn`, `exec`, `execFile`, and `fork` so child Node processes receive the Headroom preload even when OpenCode passes a custom `env`. - The child-process shim fails closed if it loads without `HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`. - This closes the subagent leak path where a child Node process could otherwise start without Headroom transport interception. ## Why this goes beyond PR #1089 PR #1089 improves OpenCode provider registration, but it still focuses on provider config shape. This PR moves the enforcement boundary to runtime transport interception. This PR goes further because: - No provider URL rewriting is required. - New providers added mid-session are covered automatically. - Subagents and child Node processes inherit the Headroom transport shim. - Direct external HTTP/2 paths fail loudly instead of leaking. - The wrap remains transparent to the user's OpenCode provider config. - The wrapper is fail-closed for unsupported child-process preload state. ## Additional robustness fixes While validating the change in Docker, the full Python suite exposed unrelated Linux/container robustness issues. These are fixed in this PR so the suite is green: - Binary cache handling now treats cache paths under a non-writable existing parent as unavailable, including when tests run as root in Docker. - `release_version.py` honors `MANUAL_VER` before git calls so direct script execution works outside a `.git` checkout. - Test logger isolation now resets relevant Headroom child loggers so proxy logging setup cannot poison later `caplog` tests. - The scanner missing-path test now uses a guaranteed missing `tmp_path` child instead of relying on `/nonexistent/path`. ## Validation All implementation validation was run inside Docker. - Full Python suite from a fresh Docker copy: `6605 passed, 523 skipped`. - Ruff on changed Python/OpenCode paths: passed. - OpenCode plugin typecheck: passed. - OpenCode plugin tests: `9 passed`. - OpenCode plugin build: passed. - Hook shim preload smoke test: passed. ## Notes This PR intentionally does not add a CLI option. `headroom wrap opencode` means full wrap. Either Headroom wraps OpenCode transparently, or the path fails loudly instead of silently leaking provider traffic. --------- Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 18:07:12 +02:00
import headroom.learn as learn_pkg
monkeypatch.setattr(learn_pkg, "registry", fake, raising=False)
def _make_project(path):
from pathlib import Path as _P
from headroom.learn.models import ProjectInfo
p = _P(path)
return ProjectInfo(name=p.name, project_path=p, data_path=p)
class TestFlushToFile:
@pytest.mark.asyncio
async def test_end_to_end_writes_per_project(self, tmp_path, monkeypatch):
"""Happy path: anchored patterns → bucketed per project → writer called."""
db = tmp_path / "memory.db"
_init_db(db)
backend = _FakeBackend(db)
feat: headroom wrap opencode / unwrap opencode CLI (#1105) ## Summary This PR implements transparent `headroom wrap opencode` support without asking users to edit OpenCode provider URLs, choose an extra CLI flag, or maintain a static provider list. The wrapper now lives at the runtime transport boundary: OpenCode keeps its user/provider config, while Headroom intercepts outbound provider traffic in-process and routes it through the local Headroom proxy. ## What changed ### Transparent OpenCode wrapping - `headroom wrap opencode` injects the `headroom-opencode` plugin through `OPENCODE_CONFIG_CONTENT`. - Existing OpenCode provider URLs are preserved. We do not rewrite user config URLs to point at Headroom. - Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are preserved. - Local OpenCode traffic, localhost traffic, and Headroom proxy traffic bypass the shim to avoid loops. ### Runtime transport interception - Added an OpenCode plugin transport shim that wraps: - `globalThis.fetch` - `http.request` / `http.get` - `https.request` / `https.get` - External provider calls are routed to the local Headroom proxy. - The original upstream origin is passed through `x-headroom-base-url`, so the proxy can forward to the real provider without changing OpenCode config. - External `http2.connect` is blocked loudly instead of allowing direct provider traffic to leak outside Headroom. ### Live provider additions Provider coverage is no longer based on a static config scan. Because routing happens at outbound request time, providers added mid-session are routed through Headroom automatically as long as they use the covered Node transport paths. ### Subagent and child-process coverage - The parent OpenCode plugin sets a packaged Node preload shim through `NODE_OPTIONS=--import=.../hook-shim/handler.js`. - The transport shim patches `child_process.spawn`, `exec`, `execFile`, and `fork` so child Node processes receive the Headroom preload even when OpenCode passes a custom `env`. - The child-process shim fails closed if it loads without `HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`. - This closes the subagent leak path where a child Node process could otherwise start without Headroom transport interception. ## Why this goes beyond PR #1089 PR #1089 improves OpenCode provider registration, but it still focuses on provider config shape. This PR moves the enforcement boundary to runtime transport interception. This PR goes further because: - No provider URL rewriting is required. - New providers added mid-session are covered automatically. - Subagents and child Node processes inherit the Headroom transport shim. - Direct external HTTP/2 paths fail loudly instead of leaking. - The wrap remains transparent to the user's OpenCode provider config. - The wrapper is fail-closed for unsupported child-process preload state. ## Additional robustness fixes While validating the change in Docker, the full Python suite exposed unrelated Linux/container robustness issues. These are fixed in this PR so the suite is green: - Binary cache handling now treats cache paths under a non-writable existing parent as unavailable, including when tests run as root in Docker. - `release_version.py` honors `MANUAL_VER` before git calls so direct script execution works outside a `.git` checkout. - Test logger isolation now resets relevant Headroom child loggers so proxy logging setup cannot poison later `caplog` tests. - The scanner missing-path test now uses a guaranteed missing `tmp_path` child instead of relying on `/nonexistent/path`. ## Validation All implementation validation was run inside Docker. - Full Python suite from a fresh Docker copy: `6605 passed, 523 skipped`. - Ruff on changed Python/OpenCode paths: passed. - OpenCode plugin typecheck: passed. - OpenCode plugin tests: `9 passed`. - OpenCode plugin build: passed. - Hook shim preload smoke test: passed. ## Notes This PR intentionally does not add a CLI option. `headroom wrap opencode` means full wrap. Either Headroom wraps OpenCode transparently, or the path fails loudly instead of silently leaking provider traffic. --------- Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 18:07:12 +02:00
project_path = tmp_path.resolve()
learner = TrafficLearner(backend=backend, agent_type="claude", min_evidence=2)
writer = _FakeWriter()
feat: headroom wrap opencode / unwrap opencode CLI (#1105) ## Summary This PR implements transparent `headroom wrap opencode` support without asking users to edit OpenCode provider URLs, choose an extra CLI flag, or maintain a static provider list. The wrapper now lives at the runtime transport boundary: OpenCode keeps its user/provider config, while Headroom intercepts outbound provider traffic in-process and routes it through the local Headroom proxy. ## What changed ### Transparent OpenCode wrapping - `headroom wrap opencode` injects the `headroom-opencode` plugin through `OPENCODE_CONFIG_CONTENT`. - Existing OpenCode provider URLs are preserved. We do not rewrite user config URLs to point at Headroom. - Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are preserved. - Local OpenCode traffic, localhost traffic, and Headroom proxy traffic bypass the shim to avoid loops. ### Runtime transport interception - Added an OpenCode plugin transport shim that wraps: - `globalThis.fetch` - `http.request` / `http.get` - `https.request` / `https.get` - External provider calls are routed to the local Headroom proxy. - The original upstream origin is passed through `x-headroom-base-url`, so the proxy can forward to the real provider without changing OpenCode config. - External `http2.connect` is blocked loudly instead of allowing direct provider traffic to leak outside Headroom. ### Live provider additions Provider coverage is no longer based on a static config scan. Because routing happens at outbound request time, providers added mid-session are routed through Headroom automatically as long as they use the covered Node transport paths. ### Subagent and child-process coverage - The parent OpenCode plugin sets a packaged Node preload shim through `NODE_OPTIONS=--import=.../hook-shim/handler.js`. - The transport shim patches `child_process.spawn`, `exec`, `execFile`, and `fork` so child Node processes receive the Headroom preload even when OpenCode passes a custom `env`. - The child-process shim fails closed if it loads without `HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`. - This closes the subagent leak path where a child Node process could otherwise start without Headroom transport interception. ## Why this goes beyond PR #1089 PR #1089 improves OpenCode provider registration, but it still focuses on provider config shape. This PR moves the enforcement boundary to runtime transport interception. This PR goes further because: - No provider URL rewriting is required. - New providers added mid-session are covered automatically. - Subagents and child Node processes inherit the Headroom transport shim. - Direct external HTTP/2 paths fail loudly instead of leaking. - The wrap remains transparent to the user's OpenCode provider config. - The wrapper is fail-closed for unsupported child-process preload state. ## Additional robustness fixes While validating the change in Docker, the full Python suite exposed unrelated Linux/container robustness issues. These are fixed in this PR so the suite is green: - Binary cache handling now treats cache paths under a non-writable existing parent as unavailable, including when tests run as root in Docker. - `release_version.py` honors `MANUAL_VER` before git calls so direct script execution works outside a `.git` checkout. - Test logger isolation now resets relevant Headroom child loggers so proxy logging setup cannot poison later `caplog` tests. - The scanner missing-path test now uses a guaranteed missing `tmp_path` child instead of relying on `/nonexistent/path`. ## Validation All implementation validation was run inside Docker. - Full Python suite from a fresh Docker copy: `6605 passed, 523 skipped`. - Ruff on changed Python/OpenCode paths: passed. - OpenCode plugin typecheck: passed. - OpenCode plugin tests: `9 passed`. - OpenCode plugin build: passed. - Hook shim preload smoke test: passed. ## Notes This PR intentionally does not add a CLI option. `headroom wrap opencode` means full wrap. Either Headroom wraps OpenCode transparently, or the path fails loudly instead of silently leaking provider traffic. --------- Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 18:07:12 +02:00
writer.files_to_return = [project_path / "CLAUDE.md"]
proj = _make_project(str(project_path))
plugin = _FakePlugin(roots=[proj], writer=writer)
_install_plugin_registry(monkeypatch, plugin)
# Need the save worker running so accumulated patterns actually land in
# the DB where flush_to_file reads them.
await learner.start()
try:
def mk() -> ExtractedPattern:
return ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
feat: headroom wrap opencode / unwrap opencode CLI (#1105) ## Summary This PR implements transparent `headroom wrap opencode` support without asking users to edit OpenCode provider URLs, choose an extra CLI flag, or maintain a static provider list. The wrapper now lives at the runtime transport boundary: OpenCode keeps its user/provider config, while Headroom intercepts outbound provider traffic in-process and routes it through the local Headroom proxy. ## What changed ### Transparent OpenCode wrapping - `headroom wrap opencode` injects the `headroom-opencode` plugin through `OPENCODE_CONFIG_CONTENT`. - Existing OpenCode provider URLs are preserved. We do not rewrite user config URLs to point at Headroom. - Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are preserved. - Local OpenCode traffic, localhost traffic, and Headroom proxy traffic bypass the shim to avoid loops. ### Runtime transport interception - Added an OpenCode plugin transport shim that wraps: - `globalThis.fetch` - `http.request` / `http.get` - `https.request` / `https.get` - External provider calls are routed to the local Headroom proxy. - The original upstream origin is passed through `x-headroom-base-url`, so the proxy can forward to the real provider without changing OpenCode config. - External `http2.connect` is blocked loudly instead of allowing direct provider traffic to leak outside Headroom. ### Live provider additions Provider coverage is no longer based on a static config scan. Because routing happens at outbound request time, providers added mid-session are routed through Headroom automatically as long as they use the covered Node transport paths. ### Subagent and child-process coverage - The parent OpenCode plugin sets a packaged Node preload shim through `NODE_OPTIONS=--import=.../hook-shim/handler.js`. - The transport shim patches `child_process.spawn`, `exec`, `execFile`, and `fork` so child Node processes receive the Headroom preload even when OpenCode passes a custom `env`. - The child-process shim fails closed if it loads without `HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`. - This closes the subagent leak path where a child Node process could otherwise start without Headroom transport interception. ## Why this goes beyond PR #1089 PR #1089 improves OpenCode provider registration, but it still focuses on provider config shape. This PR moves the enforcement boundary to runtime transport interception. This PR goes further because: - No provider URL rewriting is required. - New providers added mid-session are covered automatically. - Subagents and child Node processes inherit the Headroom transport shim. - Direct external HTTP/2 paths fail loudly instead of leaking. - The wrap remains transparent to the user's OpenCode provider config. - The wrapper is fail-closed for unsupported child-process preload state. ## Additional robustness fixes While validating the change in Docker, the full Python suite exposed unrelated Linux/container robustness issues. These are fixed in this PR so the suite is green: - Binary cache handling now treats cache paths under a non-writable existing parent as unavailable, including when tests run as root in Docker. - `release_version.py` honors `MANUAL_VER` before git calls so direct script execution works outside a `.git` checkout. - Test logger isolation now resets relevant Headroom child loggers so proxy logging setup cannot poison later `caplog` tests. - The scanner missing-path test now uses a guaranteed missing `tmp_path` child instead of relying on `/nonexistent/path`. ## Validation All implementation validation was run inside Docker. - Full Python suite from a fresh Docker copy: `6605 passed, 523 skipped`. - Ruff on changed Python/OpenCode paths: passed. - OpenCode plugin typecheck: passed. - OpenCode plugin tests: `9 passed`. - OpenCode plugin build: passed. - Hook shim preload smoke test: passed. ## Notes This PR intentionally does not add a CLI option. `headroom wrap opencode` means full wrap. Either Headroom wraps OpenCode transparently, or the path fails loudly instead of silently leaking provider traffic. --------- Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 18:07:12 +02:00
content=f"Use /usr/bin/python3 at {project_path}/main.py",
importance=0.6,
)
# Two sightings → save at evidence_count=2 (crosses live-flush gate).
await learner._accumulate(mk())
await learner._accumulate(mk())
await _wait_for_saved(learner, 1, db)
await learner.flush_to_file()
finally:
await learner.stop()
assert len(writer.calls) >= 1
recs, written_proj, dry_run = writer.calls[0]
assert dry_run is False
assert written_proj is proj
assert len(recs) == 1
assert "python3" in recs[0].content
@pytest.mark.asyncio
async def test_shutdown_flush_respects_min_evidence(self, tmp_path, monkeypatch):
"""Regression: stop() must not bypass the evidence gate.
Earlier behavior collapsed min_evidence to 1 at shutdown, persisting
every singleton pattern. This is exactly inverted: singletons are the
*least* trustworthy patterns. The gate must use self._min_evidence at
all times, including stop()'s final flush.
"""
writer = _FakeWriter()
proj = _make_project(str(tmp_path))
plugin = _FakePlugin(roots=[proj], writer=writer)
_install_plugin_registry(monkeypatch, plugin)
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=5)
# Singleton pattern: should NOT survive the shutdown flush.
learner._pattern_counts["h"] = (
ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content=f"singleton at {tmp_path}/main.py",
importance=0.5,
evidence_count=1,
),
1,
)
await learner.stop() # triggers a final flush_to_file
assert writer.calls == [], "singleton survived the shutdown gate"
@pytest.mark.asyncio
async def test_early_returns_no_plugin(self, monkeypatch):
"""No plugin detected → flush is a no-op."""
learner = TrafficLearner(backend=None, agent_type="unknown", min_evidence=1)
_install_plugin_registry(monkeypatch, None)
# Seed an accumulator entry so the check isn't vacuously "no patterns".
learner._pattern_counts["h"] = (
ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="x",
importance=0.5,
evidence_count=2,
),
2,
)
await learner.flush_to_file() # returns without raising
@pytest.mark.asyncio
async def test_early_return_no_patterns(self, monkeypatch):
"""Empty accumulator and empty DB → flush returns without calling writer."""
writer = _FakeWriter()
plugin = _FakePlugin(roots=[_make_project("/x/a")], writer=writer)
_install_plugin_registry(monkeypatch, plugin)
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
await learner.flush_to_file()
assert writer.calls == []
@pytest.mark.asyncio
async def test_discover_projects_failure_is_swallowed(self, monkeypatch):
"""If plugin.discover_projects raises, flush logs and returns."""
writer = _FakeWriter()
plugin = _FakePlugin(roots=[], writer=writer, discover_raises=True)
_install_plugin_registry(monkeypatch, plugin)
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
learner._pattern_counts["h"] = (
ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="whatever",
importance=0.5,
evidence_count=2,
),
2,
)
await learner.flush_to_file()
assert writer.calls == [] # no roots → short-circuits before writer
fix(learn): run project discovery off the event loop (#2731) ## Description `TrafficLearner.flush_to_file` is a coroutine, but it called `plugin.discover_projects()` inline. That function walks the filesystem to decode escaped project directory names — in `learn/plugins/claude.py`, `_greedy_path_decode` recurses through `iterdir()` at every level and tries each tokenization of each child, backtracking on a miss — so on a large home tree it runs for minutes. Doing that on the event loop freezes uvicorn for the whole window. The port keeps accepting TCP, but `/readyz` never answers, so a supervisor health-checking the proxy kills a process that is merely busy. Field thread dumps show exactly that: ``` Current thread (most recent call first): File "python3.12/pathlib.py", line 1056 in iterdir File "headroom/learn/plugins/claude.py", line 454 in _greedy_path_decode File "headroom/learn/plugins/claude.py", line 478 in _greedy_path_decode File "headroom/learn/plugins/claude.py", line 478 in _greedy_path_decode File "headroom/learn/plugins/claude.py", line 426 in _decode_project_path File "headroom/learn/plugins/claude.py", line 71 in discover_projects File "headroom/memory/traffic_learner.py", line 591 in flush_to_file File "headroom/memory/traffic_learner.py", line 535 in _flush_worker File "python3.12/asyncio/events.py", line 88 in _run File "python3.12/asyncio/base_events.py", line 1999 in _run_once File "python3.12/asyncio/base_events.py", line 645 in run_forever File "uvicorn/server.py", line 75 in run File "headroom/proxy/server.py", line 4992 in run_server ``` Accompanying signals from the same incidents: port accepts TCP, `/readyz` times out, process CPU 2-13s across the window (I/O bound, not spinning), proxy log silent 66-336s. ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/memory/traffic_learner.py`: `flush_to_file` now awaits `asyncio.to_thread(plugin.discover_projects)` instead of calling it inline. `asyncio` was already imported. The result is cached per learner (`_project_roots_cache`), so the steady-state flush path pays nothing for the thread hop. - `tests/test_memory/test_traffic_learner.py`: added `test_discover_projects_does_not_block_the_event_loop`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uv run --frozen --extra dev pytest tests/test_memory/test_traffic_learner.py -q .................................................. [100%] ============================= 152 passed in 2.93s ============================== $ uvx ruff check headroom/memory/traffic_learner.py tests/test_memory/test_traffic_learner.py All checks passed! $ uvx ruff format --check headroom/memory/traffic_learner.py tests/test_memory/test_traffic_learner.py 2 files already formatted $ uv run --frozen --extra dev mypy headroom/memory/traffic_learner.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: macOS 15.6 arm64, Python 3.10.18, pytest 9.0.3, branch off `main` @ `01df2452`. - Exact command / steps: reverted only the one-line source change in the working tree (`await asyncio.to_thread(plugin.discover_projects)` back to `plugin.discover_projects()`), left the new test in place, ran `uv run --frozen --extra dev pytest tests/test_memory/test_traffic_learner.py -k does_not_block -q`, then restored the line and re-ran the full file. - Observed result: without the change the test fails — `flush_to_file` runs to completion synchronously the moment the task is created, so the loop never regains control while `discover_projects` is parked on a `threading.Event`. With the change the loop stays responsive and the flush completes once discovery returns. Full file: 152 passed. - Not tested: no live proxy run against a multi-minute real home tree; the blocking behaviour is reproduced deterministically in the test instead. The thread dump above is captured field evidence, not a run in this environment. Failing output with the fix reverted: ```text $ uv run --frozen --extra dev pytest tests/test_memory/test_traffic_learner.py -k does_not_block -q tests/test_memory/test_traffic_learner.py:1254: in test_discover_projects_does_not_block_the_event_loop assert not flush.done() E AssertionError: assert not True E + where True = <built-in method done of _asyncio.Task object at 0x10882dff0>() E + where <built-in method done of _asyncio.Task object at 0x10882dff0> = <Task finished name='Task-1' coro=<TrafficLearner.flush_to_file() done ...>>.done ========================= 1 failed, 151 deselected in 5.51s ========================= ``` ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes - Documentation: N/A — no user-facing behaviour or interface change. - Bounding `_greedy_path_decode`'s backtracking is the real cost fix and belongs in its own change. This one only stops a slow walk from taking the server's liveness with it.
2026-08-03 15:00:52 +02:00
@pytest.mark.asyncio
async def test_discover_projects_does_not_block_the_event_loop(self, tmp_path, monkeypatch):
"""A slow discover_projects must not stall other loop work.
discover_projects walks the filesystem; on a large home tree it takes
minutes. Called inline it froze the loop, so uvicorn stopped answering
/readyz and supervisors killed a proxy that was only busy.
"""
writer = _FakeWriter()
project_path = tmp_path.resolve()
plugin = _FakePlugin(roots=[_make_project(str(project_path))], writer=writer)
release = threading.Event()
def slow_discover():
release.wait(timeout=5)
return [_make_project(str(project_path))]
plugin.discover_projects = slow_discover # type: ignore[method-assign]
_install_plugin_registry(monkeypatch, plugin)
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
learner._pattern_counts["h"] = (
ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content=f"Working test command: cd {project_path} && pytest",
importance=0.5,
evidence_count=2,
),
2,
)
flush = asyncio.create_task(learner.flush_to_file())
# The loop stays responsive while discover_projects is stuck.
await asyncio.wait_for(asyncio.sleep(0), timeout=1)
assert not flush.done()
release.set()
await asyncio.wait_for(flush, timeout=5)
assert writer.calls, "flush should still complete once discovery returns"
@pytest.mark.asyncio
async def test_unanchored_patterns_dropped(self, tmp_path, monkeypatch):
"""Patterns with no path anchoring are dropped before writer is called."""
writer = _FakeWriter()
feat: headroom wrap opencode / unwrap opencode CLI (#1105) ## Summary This PR implements transparent `headroom wrap opencode` support without asking users to edit OpenCode provider URLs, choose an extra CLI flag, or maintain a static provider list. The wrapper now lives at the runtime transport boundary: OpenCode keeps its user/provider config, while Headroom intercepts outbound provider traffic in-process and routes it through the local Headroom proxy. ## What changed ### Transparent OpenCode wrapping - `headroom wrap opencode` injects the `headroom-opencode` plugin through `OPENCODE_CONFIG_CONTENT`. - Existing OpenCode provider URLs are preserved. We do not rewrite user config URLs to point at Headroom. - Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are preserved. - Local OpenCode traffic, localhost traffic, and Headroom proxy traffic bypass the shim to avoid loops. ### Runtime transport interception - Added an OpenCode plugin transport shim that wraps: - `globalThis.fetch` - `http.request` / `http.get` - `https.request` / `https.get` - External provider calls are routed to the local Headroom proxy. - The original upstream origin is passed through `x-headroom-base-url`, so the proxy can forward to the real provider without changing OpenCode config. - External `http2.connect` is blocked loudly instead of allowing direct provider traffic to leak outside Headroom. ### Live provider additions Provider coverage is no longer based on a static config scan. Because routing happens at outbound request time, providers added mid-session are routed through Headroom automatically as long as they use the covered Node transport paths. ### Subagent and child-process coverage - The parent OpenCode plugin sets a packaged Node preload shim through `NODE_OPTIONS=--import=.../hook-shim/handler.js`. - The transport shim patches `child_process.spawn`, `exec`, `execFile`, and `fork` so child Node processes receive the Headroom preload even when OpenCode passes a custom `env`. - The child-process shim fails closed if it loads without `HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`. - This closes the subagent leak path where a child Node process could otherwise start without Headroom transport interception. ## Why this goes beyond PR #1089 PR #1089 improves OpenCode provider registration, but it still focuses on provider config shape. This PR moves the enforcement boundary to runtime transport interception. This PR goes further because: - No provider URL rewriting is required. - New providers added mid-session are covered automatically. - Subagents and child Node processes inherit the Headroom transport shim. - Direct external HTTP/2 paths fail loudly instead of leaking. - The wrap remains transparent to the user's OpenCode provider config. - The wrapper is fail-closed for unsupported child-process preload state. ## Additional robustness fixes While validating the change in Docker, the full Python suite exposed unrelated Linux/container robustness issues. These are fixed in this PR so the suite is green: - Binary cache handling now treats cache paths under a non-writable existing parent as unavailable, including when tests run as root in Docker. - `release_version.py` honors `MANUAL_VER` before git calls so direct script execution works outside a `.git` checkout. - Test logger isolation now resets relevant Headroom child loggers so proxy logging setup cannot poison later `caplog` tests. - The scanner missing-path test now uses a guaranteed missing `tmp_path` child instead of relying on `/nonexistent/path`. ## Validation All implementation validation was run inside Docker. - Full Python suite from a fresh Docker copy: `6605 passed, 523 skipped`. - Ruff on changed Python/OpenCode paths: passed. - OpenCode plugin typecheck: passed. - OpenCode plugin tests: `9 passed`. - OpenCode plugin build: passed. - Hook shim preload smoke test: passed. ## Notes This PR intentionally does not add a CLI option. `headroom wrap opencode` means full wrap. Either Headroom wraps OpenCode transparently, or the path fails loudly instead of silently leaking provider traffic. --------- Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 18:07:12 +02:00
project_path = tmp_path.resolve()
plugin = _FakePlugin(roots=[_make_project(str(project_path))], writer=writer)
_install_plugin_registry(monkeypatch, plugin)
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
# Content has no absolute path — should be dropped as un-anchored.
learner._pattern_counts["h"] = (
ExtractedPattern(
category=PatternCategory.PREFERENCE,
content="User preference: use terse output",
importance=0.7,
evidence_count=2,
),
2,
)
await learner.flush_to_file()
assert writer.calls == []
@pytest.mark.asyncio
async def test_writer_exception_does_not_propagate(self, tmp_path, monkeypatch):
"""A writer raising should be logged; flush must not bubble the error."""
writer = _FakeWriter()
writer.raise_on_write = True
feat: headroom wrap opencode / unwrap opencode CLI (#1105) ## Summary This PR implements transparent `headroom wrap opencode` support without asking users to edit OpenCode provider URLs, choose an extra CLI flag, or maintain a static provider list. The wrapper now lives at the runtime transport boundary: OpenCode keeps its user/provider config, while Headroom intercepts outbound provider traffic in-process and routes it through the local Headroom proxy. ## What changed ### Transparent OpenCode wrapping - `headroom wrap opencode` injects the `headroom-opencode` plugin through `OPENCODE_CONFIG_CONTENT`. - Existing OpenCode provider URLs are preserved. We do not rewrite user config URLs to point at Headroom. - Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are preserved. - Local OpenCode traffic, localhost traffic, and Headroom proxy traffic bypass the shim to avoid loops. ### Runtime transport interception - Added an OpenCode plugin transport shim that wraps: - `globalThis.fetch` - `http.request` / `http.get` - `https.request` / `https.get` - External provider calls are routed to the local Headroom proxy. - The original upstream origin is passed through `x-headroom-base-url`, so the proxy can forward to the real provider without changing OpenCode config. - External `http2.connect` is blocked loudly instead of allowing direct provider traffic to leak outside Headroom. ### Live provider additions Provider coverage is no longer based on a static config scan. Because routing happens at outbound request time, providers added mid-session are routed through Headroom automatically as long as they use the covered Node transport paths. ### Subagent and child-process coverage - The parent OpenCode plugin sets a packaged Node preload shim through `NODE_OPTIONS=--import=.../hook-shim/handler.js`. - The transport shim patches `child_process.spawn`, `exec`, `execFile`, and `fork` so child Node processes receive the Headroom preload even when OpenCode passes a custom `env`. - The child-process shim fails closed if it loads without `HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`. - This closes the subagent leak path where a child Node process could otherwise start without Headroom transport interception. ## Why this goes beyond PR #1089 PR #1089 improves OpenCode provider registration, but it still focuses on provider config shape. This PR moves the enforcement boundary to runtime transport interception. This PR goes further because: - No provider URL rewriting is required. - New providers added mid-session are covered automatically. - Subagents and child Node processes inherit the Headroom transport shim. - Direct external HTTP/2 paths fail loudly instead of leaking. - The wrap remains transparent to the user's OpenCode provider config. - The wrapper is fail-closed for unsupported child-process preload state. ## Additional robustness fixes While validating the change in Docker, the full Python suite exposed unrelated Linux/container robustness issues. These are fixed in this PR so the suite is green: - Binary cache handling now treats cache paths under a non-writable existing parent as unavailable, including when tests run as root in Docker. - `release_version.py` honors `MANUAL_VER` before git calls so direct script execution works outside a `.git` checkout. - Test logger isolation now resets relevant Headroom child loggers so proxy logging setup cannot poison later `caplog` tests. - The scanner missing-path test now uses a guaranteed missing `tmp_path` child instead of relying on `/nonexistent/path`. ## Validation All implementation validation was run inside Docker. - Full Python suite from a fresh Docker copy: `6605 passed, 523 skipped`. - Ruff on changed Python/OpenCode paths: passed. - OpenCode plugin typecheck: passed. - OpenCode plugin tests: `9 passed`. - OpenCode plugin build: passed. - Hook shim preload smoke test: passed. ## Notes This PR intentionally does not add a CLI option. `headroom wrap opencode` means full wrap. Either Headroom wraps OpenCode transparently, or the path fails loudly instead of silently leaking provider traffic. --------- Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 18:07:12 +02:00
project_path = tmp_path.resolve()
plugin = _FakePlugin(roots=[_make_project(str(project_path))], writer=writer)
_install_plugin_registry(monkeypatch, plugin)
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
learner._pattern_counts["h"] = (
ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
feat: headroom wrap opencode / unwrap opencode CLI (#1105) ## Summary This PR implements transparent `headroom wrap opencode` support without asking users to edit OpenCode provider URLs, choose an extra CLI flag, or maintain a static provider list. The wrapper now lives at the runtime transport boundary: OpenCode keeps its user/provider config, while Headroom intercepts outbound provider traffic in-process and routes it through the local Headroom proxy. ## What changed ### Transparent OpenCode wrapping - `headroom wrap opencode` injects the `headroom-opencode` plugin through `OPENCODE_CONFIG_CONTENT`. - Existing OpenCode provider URLs are preserved. We do not rewrite user config URLs to point at Headroom. - Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are preserved. - Local OpenCode traffic, localhost traffic, and Headroom proxy traffic bypass the shim to avoid loops. ### Runtime transport interception - Added an OpenCode plugin transport shim that wraps: - `globalThis.fetch` - `http.request` / `http.get` - `https.request` / `https.get` - External provider calls are routed to the local Headroom proxy. - The original upstream origin is passed through `x-headroom-base-url`, so the proxy can forward to the real provider without changing OpenCode config. - External `http2.connect` is blocked loudly instead of allowing direct provider traffic to leak outside Headroom. ### Live provider additions Provider coverage is no longer based on a static config scan. Because routing happens at outbound request time, providers added mid-session are routed through Headroom automatically as long as they use the covered Node transport paths. ### Subagent and child-process coverage - The parent OpenCode plugin sets a packaged Node preload shim through `NODE_OPTIONS=--import=.../hook-shim/handler.js`. - The transport shim patches `child_process.spawn`, `exec`, `execFile`, and `fork` so child Node processes receive the Headroom preload even when OpenCode passes a custom `env`. - The child-process shim fails closed if it loads without `HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`. - This closes the subagent leak path where a child Node process could otherwise start without Headroom transport interception. ## Why this goes beyond PR #1089 PR #1089 improves OpenCode provider registration, but it still focuses on provider config shape. This PR moves the enforcement boundary to runtime transport interception. This PR goes further because: - No provider URL rewriting is required. - New providers added mid-session are covered automatically. - Subagents and child Node processes inherit the Headroom transport shim. - Direct external HTTP/2 paths fail loudly instead of leaking. - The wrap remains transparent to the user's OpenCode provider config. - The wrapper is fail-closed for unsupported child-process preload state. ## Additional robustness fixes While validating the change in Docker, the full Python suite exposed unrelated Linux/container robustness issues. These are fixed in this PR so the suite is green: - Binary cache handling now treats cache paths under a non-writable existing parent as unavailable, including when tests run as root in Docker. - `release_version.py` honors `MANUAL_VER` before git calls so direct script execution works outside a `.git` checkout. - Test logger isolation now resets relevant Headroom child loggers so proxy logging setup cannot poison later `caplog` tests. - The scanner missing-path test now uses a guaranteed missing `tmp_path` child instead of relying on `/nonexistent/path`. ## Validation All implementation validation was run inside Docker. - Full Python suite from a fresh Docker copy: `6605 passed, 523 skipped`. - Ruff on changed Python/OpenCode paths: passed. - OpenCode plugin typecheck: passed. - OpenCode plugin tests: `9 passed`. - OpenCode plugin build: passed. - Hook shim preload smoke test: passed. ## Notes This PR intentionally does not add a CLI option. `headroom wrap opencode` means full wrap. Either Headroom wraps OpenCode transparently, or the path fails loudly instead of silently leaking provider traffic. --------- Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 18:07:12 +02:00
content=f"Use {project_path}/tool.py",
importance=0.6,
evidence_count=2,
),
2,
)
await learner.flush_to_file() # must not raise
assert len(writer.calls) == 1
# =============================================================================
# Internal helper edge cases — _resolve_backend_db_path / _collect_all_patterns
# / _hydrate_persisted_state / _bump_persisted_evidence
# =============================================================================
class TestBackendResolution:
def test_resolve_none_backend(self):
from headroom.memory.traffic_learner import _resolve_backend_db_path
assert _resolve_backend_db_path(None) is None
def test_resolve_backend_without_config(self):
from headroom.memory.traffic_learner import _resolve_backend_db_path
class _Bare:
pass
assert _resolve_backend_db_path(_Bare()) is None
def test_resolve_backend_with_empty_db_path(self):
import types as _types
from headroom.memory.traffic_learner import _resolve_backend_db_path
backend = _types.SimpleNamespace(_config=_types.SimpleNamespace(db_path=""))
assert _resolve_backend_db_path(backend) is None
class TestCollectAllPatterns:
@pytest.mark.asyncio
async def test_merges_db_and_accumulator(self, tmp_path):
"""Patterns in both DB and accumulator get evidence_count summed by hash."""
db = tmp_path / "memory.db"
_init_db(db)
backend = _FakeBackend(db)
# Seed DB with a traffic_learner row at evidence_count=3.
await backend.save_memory(
content="shared pattern",
user_id="t",
importance=0.5,
metadata={
"source": "traffic_learner",
"category": "environment",
"evidence_count": 3,
},
)
learner = TrafficLearner(backend=backend, min_evidence=1)
# Same content in accumulator with count=2; hash matches.
p = ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="shared pattern",
importance=0.5,
)
learner._pattern_counts[p.content_hash] = (p, 2)
merged = learner._collect_all_patterns()
assert len(merged) == 1
assert merged[0].evidence_count == 3 + 2
def test_handles_missing_db_gracefully(self, tmp_path):
"""A backend pointing to a nonexistent DB is skipped, not raised."""
backend = _FakeBackend(tmp_path / "absent.db") # file not created
learner = TrafficLearner(backend=backend, min_evidence=1)
merged = learner._collect_all_patterns()
assert merged == []
class TestHydrateEdgeCases:
@pytest.mark.asyncio
async def test_no_backend(self):
"""start() with backend=None hydrates to empty state and still runs."""
learner = TrafficLearner(backend=None, min_evidence=1)
await learner.start()
try:
assert learner._saved_hashes == set()
assert learner._persisted_ids == {}
finally:
await learner.stop()
@pytest.mark.asyncio
async def test_missing_db_file(self, tmp_path):
"""Backend with a db_path that doesn't exist → hydrate is a no-op."""
backend = _FakeBackend(tmp_path / "not-there.db")
learner = TrafficLearner(backend=backend, min_evidence=1)
await learner._hydrate_persisted_state()
assert learner._saved_hashes == set()
assert learner._persisted_ids == {}
class TestBumpEdgeCases:
@pytest.mark.asyncio
async def test_bump_with_no_backend_is_noop(self):
learner = TrafficLearner(backend=None, min_evidence=1)
# Should not raise even with no backend.
await learner._bump_persisted_evidence("some-id")
@pytest.mark.asyncio
async def test_bump_with_missing_db_is_noop(self, tmp_path):
backend = _FakeBackend(tmp_path / "absent.db")
learner = TrafficLearner(backend=backend, min_evidence=1)
await learner._bump_persisted_evidence("some-id") # no exception
@pytest.mark.asyncio
async def test_bump_unknown_id_is_noop(self, tmp_path):
"""Updating a non-existent memory id silently affects zero rows."""
db = tmp_path / "memory.db"
_init_db(db)
backend = _FakeBackend(db)
learner = TrafficLearner(backend=backend, min_evidence=1)
await learner._bump_persisted_evidence("no-such-id")
assert _read_traffic_rows(db) == []
fix(memory): keep vector metadata in sync (#2295) ## Description Fixes #2296. Metadata-only memory updates can leave the primary store, vector-index metadata, and cache inconsistent. TrafficLearner also performs an atomic SQLite evidence increment that bypasses normal secondary-index refresh. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Refresh vector metadata for HierarchicalMemory metadata-only, importance, and entity-reference updates. - Add a LocalBackend path that reloads a memory from the primary store and refreshes vector metadata plus cache state. - Preserve the atomic TrafficLearner SQL evidence increment, then refresh secondary state only when a row was updated. - Keep refresh failures fail-open and distinguish them from primary-store increment failures in logs. - Add backend-neutral contract tests instead of inspecting a specific vector adapter private field. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) — verified locally: mypy 1.20.2, no issues in 504 source files - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text 141 passed, 1 skipped ruff check: passed ruff format --check: passed ``` The first CI run exposed one backend-specific test assertion against HNSW private state while CI used SQLiteVectorIndex. Commit a1aff399 removes that assertion and keeps the backend-neutral mock contract test. ## Real Behavior Proof - Environment: macOS, Python 3.13, current Headroom main. - Exact command / steps: update a Memory with metadata only through HierarchicalMemory, assert the vector index receives the updated Memory, then perform a TrafficLearner evidence bump and assert LocalBackend refresh is called only for an existing row. - Observed result: metadata-only update refreshes vector metadata without re-embedding content; evidence bump remains atomic and refreshes vector/cache state; an unknown memory ID triggers no refresh. - Not tested: remote memory backends, full repository suite, or live multi-process writers against one SQLite database. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project style guidelines - [x] I have performed a self-review of my code - [x] I have commented code where the behavior is not self-explanatory - [x] I have made corresponding documentation changes (N/A — internal bug fix, no user-facing docs/changelog impact) - [x] My changes generate no new warnings - [x] I have added tests that prove the fix is effective - [x] New and existing focused unit tests pass locally - [x] I have updated CHANGELOG.md if applicable (N/A — internal bug fix, no user-facing docs/changelog impact) ## Screenshots (if applicable) Not applicable. ## Additional Notes Draft for storage-owner feedback on the refresh API and write overhead. The refresh reuses the existing embedding and does not invoke the embedder.
2026-08-12 12:46:13 +08:00
assert backend.refreshed_ids == []
# =============================================================================
# stop() cancels the flush task
# =============================================================================
class TestStopCancels:
@pytest.mark.asyncio
async def test_stop_cancels_flush_task(self):
learner = TrafficLearner(backend=None, min_evidence=1)
await learner.start()
assert learner._flush_task is not None and not learner._flush_task.done()
await learner.stop()
assert learner._flush_task is None or learner._flush_task.done()
class TestNormalizedHash:
"""Error-recovery patterns hash on recovery intent, not literal text."""
def _mk(self, **meta) -> ExtractedPattern:
return ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content=f"content-{meta.get('tool', 'none')}-{len(meta)}",
importance=0.7,
metadata=meta,
)
def test_read_recovery_basename_hash(self):
a = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="File `/a/state.rs` does not exist. The correct path is `/a/lib.rs`.",
importance=0.7,
metadata={"tool": "Read", "error_path": "/a/state.rs", "success_path": "/a/lib.rs"},
)
b = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="File `/b/state.rs` does not exist. The correct path is `/b/lib.rs`.",
importance=0.7,
metadata={"tool": "Read", "error_path": "/b/state.rs", "success_path": "/b/lib.rs"},
)
assert a.content_hash == b.content_hash
def test_bash_recovery_tail_count_collapse(self):
a = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="Command `cargo check` fails. Use `cargo check --manifest-path src-tauri/Cargo.toml | tail -10` instead.",
importance=0.7,
metadata={
"tool": "Bash",
"failed_cmd": "cargo check",
"success_cmd": "cargo check --manifest-path src-tauri/Cargo.toml | tail -10",
},
)
b = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="Command `cargo check` fails. Use `cargo check --manifest-path src-tauri/Cargo.toml | tail -50` instead.",
importance=0.7,
metadata={
"tool": "Bash",
"failed_cmd": "cargo check",
"success_cmd": "cargo check --manifest-path src-tauri/Cargo.toml | tail -50",
},
)
assert a.content_hash == b.content_hash
def test_bash_recovery_pipe_boundary(self):
a = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="x",
importance=0.7,
metadata={
"tool": "Bash",
"failed_cmd": "grep foo bar.txt",
"success_cmd": "grep -n foo bar.txt | head -5",
},
)
b = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="y",
importance=0.7,
metadata={
"tool": "Bash",
"failed_cmd": "grep foo bar.txt",
"success_cmd": "grep -n foo bar.txt | wc -l",
},
)
assert a.content_hash == b.content_hash
def test_bash_recovery_different_primary_cmd_different_hash(self):
a = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="x",
importance=0.7,
metadata={
"tool": "Bash",
"failed_cmd": "cargo check",
"success_cmd": "cargo build",
},
)
b = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="y",
importance=0.7,
metadata={
"tool": "Bash",
"failed_cmd": "cargo check",
"success_cmd": "cargo test",
},
)
assert a.content_hash != b.content_hash
def test_non_error_recovery_unchanged(self):
a = ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="Use /usr/bin/python3.",
importance=0.7,
)
b = ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="Use /opt/bin/python3.",
importance=0.7,
)
assert a.content_hash != b.content_hash
def test_error_recovery_without_tool_falls_back_to_content(self):
"""Legacy error_recovery rows without a `tool` metadata key still work."""
a = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="Some legacy bullet.",
importance=0.7,
)
b = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="Some legacy bullet.",
importance=0.7,
)
assert a.content_hash == b.content_hash
class TestRefineErrorRecovery:
"""Render-time pipeline: hard floor, re-validate, collapse, rank, cap."""
def _mk_read(
self,
*,
error_path: str,
success_path: str,
evidence: int = 1,
last_seen: datetime | None = None,
) -> ExtractedPattern:
now = datetime.now(UTC)
return ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content=f"File `{error_path}` does not exist. The correct path is `{success_path}`.",
importance=0.7,
evidence_count=evidence,
metadata={
"tool": "Read",
"error_path": error_path,
"success_path": success_path,
},
last_seen_at=last_seen or now,
first_seen_at=last_seen or now,
)
def test_drops_patterns_beyond_hard_floor(self, tmp_path):
target = tmp_path / "lib.rs"
target.write_text("pub fn x() {}")
old = self._mk_read(
error_path=str(tmp_path / "state.rs"),
success_path=str(target),
last_seen=datetime.now(UTC) - timedelta(days=22),
)
fresh = self._mk_read(
error_path=str(tmp_path / "other.rs"),
success_path=str(target),
)
refined = _refine_error_recovery([old, fresh])
assert fresh in refined
assert old not in refined
def test_revalidates_read_success_path(self, tmp_path):
present = tmp_path / "present.rs"
present.write_text("x")
p_ok = self._mk_read(
error_path=str(tmp_path / "miss.rs"),
success_path=str(present),
)
p_missing = self._mk_read(
error_path=str(tmp_path / "other.rs"),
success_path=str(tmp_path / "gone.rs"),
)
refined = _refine_error_recovery([p_ok, p_missing])
assert p_ok in refined
assert p_missing not in refined
def test_collapses_ambiguous_error_path(self, tmp_path):
a = tmp_path / "a.rs"
a.write_text("x")
b = tmp_path / "b.rs"
b.write_text("y")
c = tmp_path / "c.rs"
c.write_text("z")
error_path = str(tmp_path / "ambiguous.rs")
group = [
self._mk_read(error_path=error_path, success_path=str(a), evidence=3),
self._mk_read(error_path=error_path, success_path=str(b), evidence=2),
self._mk_read(error_path=error_path, success_path=str(c), evidence=1),
]
refined = _refine_error_recovery(group)
assert len(refined) == 1
collapsed = refined[0]
assert collapsed.metadata.get("collapsed") is True
assert collapsed.evidence_count == 6
assert "ambiguous.rs" in collapsed.content
assert "Glob/Grep" in collapsed.content
def test_single_success_path_not_collapsed(self, tmp_path):
a = tmp_path / "a.rs"
a.write_text("x")
error_path = str(tmp_path / "only-one-target.rs")
patterns = [
self._mk_read(error_path=error_path, success_path=str(a), evidence=3),
self._mk_read(error_path=error_path, success_path=str(a), evidence=2),
]
refined = _refine_error_recovery(patterns)
# Not collapsed — only one distinct success_path.
assert all(p.metadata.get("collapsed") is not True for p in refined)
assert len(refined) == 2
def test_recency_ranking_prefers_fresh_over_stale_heavy(self, tmp_path):
target = tmp_path / "lib.rs"
target.write_text("x")
# Heavy but old: evidence=10, seen 10 days ago → score ~10 * 0.5**2 = 2.5
heavy_old = self._mk_read(
error_path=str(tmp_path / "old.rs"),
success_path=str(target),
evidence=10,
last_seen=datetime.now(UTC) - timedelta(days=10),
)
# Light but fresh: evidence=3, seen now → score ~3
light_fresh = self._mk_read(
error_path=str(tmp_path / "fresh.rs"),
success_path=str(target),
evidence=3,
)
refined = _refine_error_recovery([heavy_old, light_fresh])
assert refined[0] is light_fresh
assert refined[1] is heavy_old
def test_section_cap_enforced(self, tmp_path):
target = tmp_path / "lib.rs"
target.write_text("x")
patterns = [
self._mk_read(
error_path=str(tmp_path / f"miss_{i}.rs"),
success_path=str(target),
evidence=i + 1,
)
for i in range(25)
]
refined = _refine_error_recovery(patterns)
assert len(refined) == 15
# Highest-evidence ones kept (all are equally fresh, so evidence wins).
kept_evidence = sorted(p.evidence_count for p in refined)
assert kept_evidence[0] >= 11 # Bottom of top-15 out of 1..25
def test_read_recovery_without_success_path_not_revalidated(self):
"""Read patterns lacking `success_path` in metadata skip re-validation cleanly."""
p = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="Some legacy Read bullet",
importance=0.7,
metadata={"tool": "Read", "error_path": "/something.rs"},
last_seen_at=datetime.now(UTC),
)
refined = _refine_error_recovery([p])
assert p in refined
def test_bash_recoveries_not_revalidated(self, tmp_path):
"""Bash patterns pass through re-validation regardless of command content."""
bash_pat = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="Command `x` fails. Use `y` instead.",
importance=0.7,
evidence_count=1,
metadata={
"tool": "Bash",
"failed_cmd": "x",
"success_cmd": "y",
},
last_seen_at=datetime.now(UTC),
)
refined = _refine_error_recovery([bash_pat])
assert bash_pat in refined
def test_empty_input_returns_empty(self):
assert _refine_error_recovery([]) == []
def test_missing_timestamps_survive_one_render(self):
"""Patterns without timestamps are kept rather than silently dropped."""
p = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="legacy bullet",
importance=0.7,
)
assert p.first_seen_at is None
assert p.last_seen_at is None
refined = _refine_error_recovery([p])
assert p in refined
def test_refined_empty_skips_section_in_recommendations(self, tmp_path):
"""If all error_recovery patterns fail re-validation, no recommendation is emitted."""
# Only pattern is a Read recovery pointing at a nonexistent success_path.
stale = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="File `/a.rs` does not exist. The correct path is `/gone.rs`.",
importance=0.7,
metadata={
"tool": "Read",
"error_path": "/a.rs",
"success_path": str(tmp_path / "does-not-exist.rs"),
},
last_seen_at=datetime.now(UTC),
)
recs = _patterns_to_recommendations([stale])
# Section should be skipped entirely — no recommendation produced.
assert recs == []
def test_oserror_during_revalidation_keeps_row(self, monkeypatch):
"""Transient OS errors during path checks should not drop the row."""
p = ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content="File `/a.rs` does not exist. The correct path is `/b.rs`.",
importance=0.7,
metadata={"tool": "Read", "error_path": "/a.rs", "success_path": "/b.rs"},
last_seen_at=datetime.now(UTC),
)
def _raise(self):
raise OSError("simulated permission error")
monkeypatch.setattr("pathlib.Path.exists", _raise)
refined = _refine_error_recovery([p])
assert p in refined
class TestNormalizeBashForHash:
"""Bash command normalization for hash-key collapse."""
def test_empty_string_returns_empty(self):
assert _normalize_bash_for_hash("") == ""
def test_no_volatile_suffix_unchanged(self):
assert _normalize_bash_for_hash("cargo check") == "cargo check"
def test_strips_head_suffix(self):
assert _normalize_bash_for_hash("grep foo bar | head -20") == "grep foo bar"
def test_strips_tail_suffix(self):
assert _normalize_bash_for_hash("cargo check | tail -5") == "cargo check"
def test_strips_trailing_context_flags(self):
# The regex is anchored to end-of-string; context flags must be trailing.
assert _normalize_bash_for_hash("grep foo bar -A 3") == "grep foo bar"
def test_strips_stderr_redirect(self):
assert _normalize_bash_for_hash("cargo check 2>&1") == "cargo check"
def test_cuts_at_first_chain(self):
# && boundary collapses to just the primary command
assert _normalize_bash_for_hash("cd /tmp && ls") == "cd /tmp"
class TestParseIsoTimestamp:
"""Edge-case coverage for _parse_iso_timestamp."""
def test_none_returns_none(self):
assert _parse_iso_timestamp(None) is None
def test_empty_string_returns_none(self):
assert _parse_iso_timestamp("") is None
def test_non_string_returns_none(self):
assert _parse_iso_timestamp(12345) is None
assert _parse_iso_timestamp(3.14) is None
def test_invalid_format_returns_none(self):
assert _parse_iso_timestamp("not an iso string") is None
def test_naive_timestamp_assumed_utc(self):
parsed = _parse_iso_timestamp("2026-04-20T12:00:00")
assert parsed is not None
assert parsed.tzinfo == UTC
def test_aware_timestamp_preserved(self):
parsed = _parse_iso_timestamp("2026-04-20T12:00:00+00:00")
assert parsed is not None
assert parsed.tzinfo is not None
class TestLoadPersistedPatternsTimestamps:
"""The sqlite load path reads first_seen_at / last_seen_at correctly."""
def _make_db(self, tmp_path, rows: list[dict]):
import json as _json
import sqlite3 as _sql
db = tmp_path / "memory.db"
conn = _sql.connect(db)
conn.execute(
"CREATE TABLE memories ("
"id TEXT PRIMARY KEY, content TEXT NOT NULL, "
"metadata TEXT NOT NULL DEFAULT '{}', "
"entity_refs TEXT NOT NULL DEFAULT '[]', "
"importance REAL NOT NULL DEFAULT 0.5, "
"created_at TEXT)"
)
for i, r in enumerate(rows):
conn.execute(
"INSERT INTO memories "
"(id, content, metadata, entity_refs, importance, created_at) "
"VALUES (?,?,?,?,?,?)",
(
str(i),
r["content"],
_json.dumps(r.get("metadata", {})),
_json.dumps(r.get("entity_refs", [])),
r.get("importance", 0.5),
r.get("created_at"),
),
)
conn.commit()
conn.close()
return db
def test_reads_timestamps_from_metadata(self, tmp_path):
db = self._make_db(
tmp_path,
[
{
"content": "env bullet",
"metadata": {
"source": "traffic_learner",
"category": "environment",
"evidence_count": 3,
"first_seen_at": "2026-04-10T10:00:00+00:00",
"last_seen_at": "2026-04-20T15:00:00+00:00",
},
}
],
)
patterns = _load_persisted_patterns_from_sqlite(db)
assert len(patterns) == 1
p = patterns[0]
assert p.first_seen_at is not None
assert p.first_seen_at.year == 2026 and p.first_seen_at.month == 4
assert p.last_seen_at is not None
assert p.last_seen_at.day == 20
def test_falls_back_to_created_at(self, tmp_path):
"""When metadata has no timestamps, `created_at` is used."""
db = self._make_db(
tmp_path,
[
{
"content": "env bullet",
"metadata": {
"source": "traffic_learner",
"category": "environment",
"evidence_count": 1,
},
"created_at": "2026-03-01T09:00:00+00:00",
}
],
)
patterns = _load_persisted_patterns_from_sqlite(db)
assert len(patterns) == 1
assert patterns[0].first_seen_at is not None
assert patterns[0].first_seen_at.month == 3
# last_seen defaults to first_seen when metadata lacks both.
assert patterns[0].last_seen_at == patterns[0].first_seen_at
def test_collision_merges_timestamps_max_last_min_first(self, tmp_path):
"""Two rows collapsing to the same hash keep the widest timestamp range."""
db = self._make_db(
tmp_path,
[
{
"content": "dup bullet",
"importance": 0.4,
"metadata": {
"source": "traffic_learner",
"category": "preference",
"evidence_count": 2,
"first_seen_at": "2026-04-10T00:00:00+00:00",
"last_seen_at": "2026-04-15T00:00:00+00:00",
},
},
{
"content": "dup bullet",
"importance": 0.9,
"metadata": {
"source": "traffic_learner",
"category": "preference",
"evidence_count": 3,
"first_seen_at": "2026-04-01T00:00:00+00:00",
"last_seen_at": "2026-04-20T00:00:00+00:00",
},
},
],
)
patterns = _load_persisted_patterns_from_sqlite(db)
assert len(patterns) == 1
p = patterns[0]
assert p.evidence_count == 5
# Higher importance wins when collision merges.
assert p.importance == 0.9
assert p.first_seen_at is not None and p.first_seen_at.day == 1
assert p.last_seen_at is not None and p.last_seen_at.day == 20
def test_non_numeric_importance_falls_back_to_default(self, tmp_path):
"""Rows with an unparseable importance value use 0.5."""
import json as _json
import sqlite3 as _sql
db = tmp_path / "memory.db"
conn = _sql.connect(db)
conn.execute(
"CREATE TABLE memories ("
"id TEXT PRIMARY KEY, content TEXT NOT NULL, "
"metadata TEXT NOT NULL DEFAULT '{}', "
"entity_refs TEXT NOT NULL DEFAULT '[]', "
"importance TEXT, "
"created_at TEXT)"
)
conn.execute(
"INSERT INTO memories (id, content, metadata, importance) VALUES (?,?,?,?)",
(
"0",
"bullet",
_json.dumps(
{
"source": "traffic_learner",
"category": "environment",
"evidence_count": 1,
}
),
"not-a-number",
),
)
conn.commit()
conn.close()
patterns = _load_persisted_patterns_from_sqlite(db)
assert len(patterns) == 1
assert patterns[0].importance == 0.5
def test_malformed_metadata_json_skipped_gracefully(self, tmp_path):
"""Rows with invalid JSON metadata don't crash the load."""
import sqlite3 as _sql
db = tmp_path / "memory.db"
conn = _sql.connect(db)
conn.execute(
"CREATE TABLE memories ("
"id TEXT PRIMARY KEY, content TEXT NOT NULL, "
"metadata TEXT NOT NULL DEFAULT '{}', "
"entity_refs TEXT NOT NULL DEFAULT '[]', "
"importance REAL NOT NULL DEFAULT 0.5, "
"created_at TEXT)"
)
# Invalid JSON in metadata
conn.execute(
"INSERT INTO memories VALUES (?,?,?,?,?,?)",
("0", "bullet", "{not json", "[]", 0.5, None),
)
conn.commit()
conn.close()
# Should not raise — the row is simply skipped (no recognizable category).
patterns = _load_persisted_patterns_from_sqlite(db)
assert patterns == []
class TestBumpPersistsLastSeenAt:
"""_bump_persisted_evidence sets $.last_seen_at on every bump."""
@pytest.mark.asyncio
async def test_bump_sets_last_seen_at_in_metadata(self, tmp_path):
import sqlite3 as _sql
db = tmp_path / "memory.db"
_init_db(db)
# Seed a traffic_learner row with no last_seen_at.
import json as _json
conn = _sql.connect(db)
conn.execute(
"INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
(
"row-1",
"bullet",
_json.dumps(
{
"source": "traffic_learner",
"category": "environment",
"evidence_count": 1,
}
),
),
)
conn.commit()
conn.close()
backend = _FakeBackend(db)
learner = TrafficLearner(backend=backend, min_evidence=1)
await learner._bump_persisted_evidence("row-1")
conn = _sql.connect(db)
row = conn.execute("SELECT metadata FROM memories WHERE id='row-1'").fetchone()
conn.close()
meta = _json.loads(row[0])
assert meta["evidence_count"] == 2
assert "last_seen_at" in meta
# Should be parseable back.
parsed = _parse_iso_timestamp(meta["last_seen_at"])
assert parsed is not None
fix(memory): keep vector metadata in sync (#2295) ## Description Fixes #2296. Metadata-only memory updates can leave the primary store, vector-index metadata, and cache inconsistent. TrafficLearner also performs an atomic SQLite evidence increment that bypasses normal secondary-index refresh. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Refresh vector metadata for HierarchicalMemory metadata-only, importance, and entity-reference updates. - Add a LocalBackend path that reloads a memory from the primary store and refreshes vector metadata plus cache state. - Preserve the atomic TrafficLearner SQL evidence increment, then refresh secondary state only when a row was updated. - Keep refresh failures fail-open and distinguish them from primary-store increment failures in logs. - Add backend-neutral contract tests instead of inspecting a specific vector adapter private field. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) — verified locally: mypy 1.20.2, no issues in 504 source files - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text 141 passed, 1 skipped ruff check: passed ruff format --check: passed ``` The first CI run exposed one backend-specific test assertion against HNSW private state while CI used SQLiteVectorIndex. Commit a1aff399 removes that assertion and keeps the backend-neutral mock contract test. ## Real Behavior Proof - Environment: macOS, Python 3.13, current Headroom main. - Exact command / steps: update a Memory with metadata only through HierarchicalMemory, assert the vector index receives the updated Memory, then perform a TrafficLearner evidence bump and assert LocalBackend refresh is called only for an existing row. - Observed result: metadata-only update refreshes vector metadata without re-embedding content; evidence bump remains atomic and refreshes vector/cache state; an unknown memory ID triggers no refresh. - Not tested: remote memory backends, full repository suite, or live multi-process writers against one SQLite database. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project style guidelines - [x] I have performed a self-review of my code - [x] I have commented code where the behavior is not self-explanatory - [x] I have made corresponding documentation changes (N/A — internal bug fix, no user-facing docs/changelog impact) - [x] My changes generate no new warnings - [x] I have added tests that prove the fix is effective - [x] New and existing focused unit tests pass locally - [x] I have updated CHANGELOG.md if applicable (N/A — internal bug fix, no user-facing docs/changelog impact) ## Screenshots (if applicable) Not applicable. ## Additional Notes Draft for storage-owner feedback on the refresh API and write overhead. The refresh reuses the existing embedding and does not invoke the embedder.
2026-08-12 12:46:13 +08:00
assert backend.refreshed_ids == ["row-1"]
class TestHydrateLegacyRow:
"""Legacy rows without `category` metadata fall back to literal-content hashing."""
@pytest.mark.asyncio
async def test_hydrate_legacy_row_without_category(self, tmp_path):
import sqlite3 as _sql
db = tmp_path / "memory.db"
_init_db(db)
import json as _json
conn = _sql.connect(db)
# No `category` key in metadata — must still hydrate.
conn.execute(
"INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
(
"legacy-1",
"legacy bullet",
_json.dumps({"source": "traffic_learner"}),
),
)
conn.commit()
conn.close()
backend = _FakeBackend(db)
learner = TrafficLearner(backend=backend, min_evidence=1)
await learner._hydrate_persisted_state()
# Falls back to sha256(content) for the hash key.
import hashlib as _h
expected = _h.sha256(b"legacy bullet").hexdigest()[:16]
assert expected in learner._saved_hashes
assert learner._persisted_ids[expected] == "legacy-1"
@pytest.mark.asyncio
async def test_hydrate_skips_empty_content(self, tmp_path):
"""Rows with empty content are skipped during hydration."""
import json as _json
import sqlite3 as _sql
db = tmp_path / "memory.db"
_init_db(db)
conn = _sql.connect(db)
conn.execute(
"INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
("empty", "", _json.dumps({"source": "traffic_learner"})),
)
conn.execute(
"INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
(
"ok",
"normal bullet",
_json.dumps({"source": "traffic_learner", "category": "environment"}),
),
)
conn.commit()
conn.close()
backend = _FakeBackend(db)
learner = TrafficLearner(backend=backend, min_evidence=1)
await learner._hydrate_persisted_state()
assert "empty" not in learner._persisted_ids.values()
assert "ok" in learner._persisted_ids.values()
@pytest.mark.asyncio
async def test_hydrate_invalid_category_falls_back(self, tmp_path):
"""Unknown category values (e.g., typos) are handled as legacy rows."""
import sqlite3 as _sql
db = tmp_path / "memory.db"
_init_db(db)
import json as _json
conn = _sql.connect(db)
conn.execute(
"INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
(
"bad-cat",
"mystery bullet",
_json.dumps({"source": "traffic_learner", "category": "mystery_type"}),
),
)
conn.commit()
conn.close()
backend = _FakeBackend(db)
learner = TrafficLearner(backend=backend, min_evidence=1)
# Must not raise.
await learner._hydrate_persisted_state()
class TestCollectAllPatternsTimestamps:
"""_collect_all_patterns bumps last_seen_at on in-session re-sightings."""
@pytest.mark.asyncio
async def test_re_sighting_bumps_last_seen_at(self, tmp_path):
"""A persisted pattern re-observed in this session gets last_seen_at=now."""
import json as _json
import sqlite3 as _sql
db = tmp_path / "memory.db"
_init_db(db)
old_last_seen = "2026-01-01T00:00:00+00:00"
conn = _sql.connect(db)
conn.execute(
"INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
(
"seed-1",
"some env bullet",
_json.dumps(
{
"source": "traffic_learner",
"category": "environment",
"evidence_count": 1,
"first_seen_at": old_last_seen,
"last_seen_at": old_last_seen,
}
),
),
)
conn.commit()
conn.close()
backend = _FakeBackend(db)
learner = TrafficLearner(backend=backend, min_evidence=1)
# Simulate in-session accumulation of the same pattern.
pattern = ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content="some env bullet",
importance=0.7,
)
learner._pattern_counts[pattern.content_hash] = (pattern, 1)
merged = learner._collect_all_patterns()
assert len(merged) == 1
m = merged[0]
assert m.last_seen_at is not None
# last_seen_at should be bumped past the stale 2026-01 timestamp.
assert m.last_seen_at.year == datetime.now(UTC).year
assert m.last_seen_at > _parse_iso_timestamp(old_last_seen)
fix(memory): traffic_learner indexes system-reminder fragments as user preferences (refs #464) `TrafficLearner._extract_preferences` ran three regex patterns over raw user-message text and saved any match as a `User preference: <captured>` memory. Two compounding bugs made ~10% of the reporter's saved memories (187 of 1796) garbage: 1. **System-reminder content was matched.** Claude Code injects `<system-reminder>…</system-reminder>` blocks into user-role messages — scaffolding ("don't mention this reminder", "use colgrep instead of Grep", "never bypass signing") that hits every correction trigger. The learner happily persisted scaffolding as authoritative user preferences. 2. **Capture groups were fixed-length windows.** `(.{10,100})` grabbed the next 10–100 chars with no boundary awareness, producing mid-word truncations like `User preference: of Grep, Glob. When spawning agents, mention colgrep features a`. This change rewrites `_extract_preferences` to be **regex-free** and adds two layered defences: - `_strip_system_reminders` (literal `str.find` scan, no regex) removes `<system-reminder>…</system-reminder>` blocks from user text before any pattern matching. Unclosed reminders drop to end-of-string. Case-insensitive on the tag name only. ~95% of the reporter's noise sample comes from this single layer. - A token-based correction scanner replaces the three `re.compile` patterns. It tokenises on whitespace (lowercasing once, up front), matches trigger sequences as ordered token lists (`don't`, `do not`, `stop`, `never`, `avoid`, `no use`, `no try`, `no do`, `instead`), and captures the trailing content until a sentence terminator (`.!?\n`) or end-of-input. Captures shorter than 10 chars are rejected (stray triggers), and captures that hit the 78/98-char cap without finding a terminator are rejected (rambling fragments). The former noise — `colgrep instead of Grep, Glob. When spawning…` — fails this gate; short complete user utterances (`don't use git push, I'll push manually`) still pass because end-of-input counts as a boundary. Net regex count in this file: -3, +0. `_hydrate_persisted_state` already runs in `start()` and seeds `_saved_hashes`/`_persisted_ids` from prior rows, so cross-restart dedup is already wired up — the reporter's "doesn't survive restarts" note was partially outdated. The narrow remaining edge (in-process `_dedup_window=100` eviction within a single very-long-running process) self-heals on next restart and is left as a separate follow-up. Tests: 17 new across `TestStripSystemReminders`, `TestExtractPreferencesSystemReminderFiltering`, `TestExtractPreferencesRealCorrections`, and `TestExtractPreferencesSentenceBoundary`. Full traffic_learner suite: 139 passing. ci-precheck green.
2026-05-13 16:13:04 -07:00
# =============================================================================
# Regression tests for GH #464:
# * <system-reminder> blocks must not feed _extract_preferences
# * correction capture groups must end on a sentence boundary, not on a
# fixed-length window
# =============================================================================
class TestStripSystemReminders:
"""Verify the literal-scan stripper does what the regex would do without
introducing a new regex pattern into the learner."""
def test_empty_and_no_tag_passthrough(self) -> None:
assert TrafficLearner._strip_system_reminders("") == ""
assert TrafficLearner._strip_system_reminders("hello world") == "hello world"
def test_basic_strip(self) -> None:
assert (
TrafficLearner._strip_system_reminders("a<system-reminder>X</system-reminder>b") == "ab"
)
def test_case_insensitive_tag_name(self) -> None:
assert (
TrafficLearner._strip_system_reminders("a<System-Reminder>X</system-reminder>b") == "ab"
)
def test_multiple_reminders(self) -> None:
text = "a<system-reminder>X</system-reminder>b<system-reminder>Y</system-reminder>c"
assert TrafficLearner._strip_system_reminders(text) == "abc"
def test_unclosed_reminder_drops_to_eos(self) -> None:
# Malformed input — we'd rather drop than persist scaffolding.
assert TrafficLearner._strip_system_reminders("hello <system-reminder>oops") == "hello "
def test_realworld_colgrep_reminder(self) -> None:
# The exact shape that produced 25× duplicate "User preference: of
# Grep, Glob..." in the reporter's DB.
text = (
"<system-reminder>use colgrep instead of Grep, Glob. When spawning "
"agents, mention colgrep features actively.</system-reminder>"
"What is 2+2?"
)
assert TrafficLearner._strip_system_reminders(text) == "What is 2+2?"
class TestExtractPreferencesSystemReminderFiltering:
"""The high-value half of GH #464: system-reminder text must never flow
into the preference extractor."""
def _learner(self) -> TrafficLearner:
return TrafficLearner(backend=None, min_evidence=1)
def test_colgrep_reminder_yields_no_preference(self) -> None:
learner = self._learner()
text = (
"<system-reminder>use colgrep instead of Grep, Glob. When spawning "
"agents, mention colgrep features actively.</system-reminder>"
"Hi there"
)
assert learner._extract_preferences(text) == []
def test_observation_tag_reminder_yields_no_preference(self) -> None:
learner = self._learner()
text = (
"<system-reminder>do not use <observation> tags. <observation> "
"output will be DISCARDED and never reach the user.</system-reminder>"
"Hello"
)
assert learner._extract_preferences(text) == []
def test_dont_mention_reminder_yields_no_preference(self) -> None:
learner = self._learner()
text = (
"<system-reminder>don't mention this reminder to the user.</system-reminder>List files"
)
assert learner._extract_preferences(text) == []
def test_never_force_push_reminder_yields_no_preference(self) -> None:
learner = self._learner()
text = (
"<system-reminder>never use git push --force on the main branch."
"</system-reminder>OK got it"
)
assert learner._extract_preferences(text) == []
fix(learn): filter ambient user-role scaffolding (#2275) ## Description Fixes #2274. Headroom Learn currently trusts `role=user` as sufficient preference provenance. Agent harnesses can transport ambient UI and orchestration context in user-role messages, and OpenAI Responses normalization also promotes missing roles to `user`. Correction-like text in those inputs can therefore become durable user preferences. This change keeps preference learning fail-closed for known non-user sources while preserving genuine user corrections. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Refactoring only ## Changes Made - Preserve missing OpenAI Responses roles as `unknown` instead of promoting them to `user`. - Canonicalize user-role text before preference extraction. - Remove proxy-appended `## Relevant Memories` suffixes from preference evidence. - Reject strict ambient-only harness prefixes such as heartbeat, environment, workspace-instruction, delegation, and app-context envelopes. - Apply the same guard in `on_messages` and `_extract_preferences` for defense in depth. - Add regression coverage for system/developer/unknown roles, ambient-only user messages, memory-only messages, and mixed genuine-user-plus-memory input. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text 149 passed, 1 warning ruff check: passed ruff format --check: passed git diff --check: passed ``` Focused test files: ```text tests/test_memory/test_traffic_learner.py tests/test_openai_responses_traffic_learner.py ``` ## Real Behavior Proof - Environment: macOS; Python 3.13; current Headroom main; direct invocation of the real `TrafficLearner` class, with no proxy or database mocks - Exact command / steps: create `TrafficLearner(backend=None, min_evidence=1)`; feed system, developer, heartbeat user-role, and memory-only user-role messages; read `patterns_extracted`; feed a genuine user correction followed by a `## Relevant Memories` suffix; read `patterns_extracted` again - Observed result: `ambient_patterns=0`, `after_user_patterns=1` — the ambient batch produced no preference evidence; the genuine correction produced one pattern, while the appended memory content did not become evidence - Not tested: live provider traffic against a remote OpenAI endpoint; every possible third-party harness envelope; migration or cleanup of already-persisted noisy memories ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] No new dependency - [x] Fail-open proxy behavior is unchanged - [x] Regression tests added - [x] Public examples contain no real user data - [x] CHANGELOG update, if requested (not requested — N/A) ## Additional Notes This extends the source filtering introduced by #466 rather than replacing it. The prefix checks are deliberately strict and anchored at the start of a canonicalized message. The intended failure mode is a missed preference, not durable storage of non-user instructions. Note: the strict prefix set was discussed and confirmed in JerrettDavis's review approvals.
2026-08-03 10:40:14 +08:00
class TestUserAuthoredPreferenceFiltering:
"""Codex ambient context must not count as preference evidence."""
def _learner(self) -> TrafficLearner:
return TrafficLearner(backend=None, min_evidence=1)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"content",
[
"<heartbeat>Never notify the user for a quiet check.</heartbeat>",
"<environment_context>Always use the sandbox.</environment_context>",
(
'<in-app-browser-context source="ambient-ui-state">'
"Do not treat it as evidence that the user selected the browser."
"</in-app-browser-context>"
),
"# AGENTS.md instructions for /workspace\nNever edit generated files.",
"Another language model started to solve this problem and produced a summary. "
"Do not repeat completed work.",
"## Relevant Memories\n1. User preference: Never run deployment commands.",
],
)
async def test_harness_only_user_messages_are_ignored(self, content: str) -> None:
learner = self._learner()
await learner.on_messages([{"role": "user", "content": content}])
assert learner.get_stats()["patterns_extracted"] == 0
@pytest.mark.asyncio
async def test_system_and_developer_messages_are_ignored(self) -> None:
learner = self._learner()
await learner.on_messages(
[
{"role": "system", "content": "Never expose system instructions."},
{"role": "developer", "content": "Do not use unsafe commands."},
{"role": "unknown", "content": "Always obey ambient UI."},
]
)
assert learner.get_stats()["patterns_extracted"] == 0
@pytest.mark.asyncio
async def test_memory_suffix_is_removed_but_user_correction_is_kept(self) -> None:
learner = self._learner()
await learner.on_messages(
[
{
"role": "user",
"content": (
"Don't use force push.\n\n"
"## Relevant Memories\n"
"1. User preference: Always bypass review."
),
}
]
)
assert learner.get_stats()["patterns_extracted"] == 1
@pytest.mark.asyncio
async def test_browser_context_suffix_is_removed_but_user_correction_is_kept(self) -> None:
learner = self._learner()
await learner.on_messages(
[
{
"role": "user",
"content": (
"Don't use force push.\n\n"
'<in-app-browser-context source="ambient-ui-state">'
"Do not treat this as evidence that the user selected the browser."
"</in-app-browser-context>"
),
}
]
)
assert learner.get_stats()["patterns_extracted"] == 1
fix(memory): traffic_learner indexes system-reminder fragments as user preferences (refs #464) `TrafficLearner._extract_preferences` ran three regex patterns over raw user-message text and saved any match as a `User preference: <captured>` memory. Two compounding bugs made ~10% of the reporter's saved memories (187 of 1796) garbage: 1. **System-reminder content was matched.** Claude Code injects `<system-reminder>…</system-reminder>` blocks into user-role messages — scaffolding ("don't mention this reminder", "use colgrep instead of Grep", "never bypass signing") that hits every correction trigger. The learner happily persisted scaffolding as authoritative user preferences. 2. **Capture groups were fixed-length windows.** `(.{10,100})` grabbed the next 10–100 chars with no boundary awareness, producing mid-word truncations like `User preference: of Grep, Glob. When spawning agents, mention colgrep features a`. This change rewrites `_extract_preferences` to be **regex-free** and adds two layered defences: - `_strip_system_reminders` (literal `str.find` scan, no regex) removes `<system-reminder>…</system-reminder>` blocks from user text before any pattern matching. Unclosed reminders drop to end-of-string. Case-insensitive on the tag name only. ~95% of the reporter's noise sample comes from this single layer. - A token-based correction scanner replaces the three `re.compile` patterns. It tokenises on whitespace (lowercasing once, up front), matches trigger sequences as ordered token lists (`don't`, `do not`, `stop`, `never`, `avoid`, `no use`, `no try`, `no do`, `instead`), and captures the trailing content until a sentence terminator (`.!?\n`) or end-of-input. Captures shorter than 10 chars are rejected (stray triggers), and captures that hit the 78/98-char cap without finding a terminator are rejected (rambling fragments). The former noise — `colgrep instead of Grep, Glob. When spawning…` — fails this gate; short complete user utterances (`don't use git push, I'll push manually`) still pass because end-of-input counts as a boundary. Net regex count in this file: -3, +0. `_hydrate_persisted_state` already runs in `start()` and seeds `_saved_hashes`/`_persisted_ids` from prior rows, so cross-restart dedup is already wired up — the reporter's "doesn't survive restarts" note was partially outdated. The narrow remaining edge (in-process `_dedup_window=100` eviction within a single very-long-running process) self-heals on next restart and is left as a separate follow-up. Tests: 17 new across `TestStripSystemReminders`, `TestExtractPreferencesSystemReminderFiltering`, `TestExtractPreferencesRealCorrections`, and `TestExtractPreferencesSentenceBoundary`. Full traffic_learner suite: 139 passing. ci-precheck green.
2026-05-13 16:13:04 -07:00
class TestExtractPreferencesRealCorrections:
"""Make sure the noise filter does not eat genuine user corrections."""
def _learner(self) -> TrafficLearner:
return TrafficLearner(backend=None, min_evidence=1)
def test_dont_correction_with_sentence_boundary(self) -> None:
learner = self._learner()
out = learner._extract_preferences("don't use double quotes in the SQL, use single quotes.")
assert len(out) == 1
assert out[0].category is PatternCategory.PREFERENCE
assert "double quotes" in out[0].content
def test_no_use_correction(self) -> None:
learner = self._learner()
out = learner._extract_preferences("No, use httpx not requests.")
assert len(out) == 1
assert "httpx" in out[0].content
def test_instead_correction(self) -> None:
learner = self._learner()
out = learner._extract_preferences("Instead, render the table with rich tables.")
assert len(out) == 1
assert "render the table" in out[0].content
class TestExtractPreferencesSentenceBoundary:
"""The tighter capture group must reject mid-sentence rambling so we
never persist fragments like ``of Grep, Glob. When spawning agents``."""
def _learner(self) -> TrafficLearner:
return TrafficLearner(backend=None, min_evidence=1)
def test_long_unbroken_paragraph_yields_no_preference(self) -> None:
learner = self._learner()
# 100+ chars after the trigger word with no '.', '!', '?', or
# '\n' anywhere — the kind of payload that would have matched
# the old ``.{10,100}`` regex and produced a mid-word
# truncation. The new bound forbids it: we need a terminator
# OR end-of-string within 98 chars of the trigger.
long_no_terminator = (
"don't use Grep when running benchmarks because it floods the output "
"buffer with a lot of irrelevant context that"
)
assert learner._extract_preferences(long_no_terminator) == []
def test_short_utterance_without_terminator_still_matches(self) -> None:
# Relaxation: a short user utterance without trailing
# punctuation is a complete thought, not a truncation. End-of-
# input counts as a boundary as long as the captured length
# fits the 898 char window.
learner = self._learner()
out = learner._extract_preferences("don't use git push, I'll push manually")
assert len(out) == 1
assert "git push" in out[0].content
def test_terminator_inside_window_captures_to_terminator(self) -> None:
learner = self._learner()
# The capture should end at the first '.', not include the
# following sentence.
out = learner._extract_preferences(
"don't use Grep at all. Use ripgrep instead because it is faster."
)
assert len(out) == 1
content = out[0].content
assert "Use ripgrep instead" not in content
assert "Grep" in content
def test_trailing_terminator_is_stripped(self) -> None:
learner = self._learner()
out = learner._extract_preferences("Never commit secrets to git.")
assert len(out) == 1
# Pref must not end on its sentence terminator.
assert not out[0].content.endswith(".")
assert not out[0].content.endswith("!")
assert not out[0].content.endswith("?")