2026-03-20 15:36:06 -07:00
|
|
|
|
"""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
|
fix(memory): collapse and decay error_recovery patterns in MEMORY.md
The Learned: error recovery section was bloating with stale, near-duplicate,
and contradictory entries because the dedup key was the literal rendered
bullet text and there was no TTL or re-validation.
- Normalize the hash key for error_recovery patterns. Read recoveries key
on (basename(error_path), basename(success_path)); Bash recoveries strip
volatile suffixes (| tail -N, 2>&1, etc.) and hash only the primary
command before the first | or &&. Non-error-recovery categories keep
literal-content hashing.
- Stamp first_seen_at / last_seen_at on every pattern; bump both in
_bump_persisted_evidence via json_set. Stored in metadata JSON — no
schema change.
- Refine at render time (error_recovery only): drop rows not re-observed
in 21 days, re-validate Read success paths against the filesystem,
collapse same-error_path-with-multiple-targets into one "use Glob/Grep
first" bullet, rank by evidence_count * 0.5 ** (days/5), cap at 15
bullets.
15 new tests (TestNormalizedHash, TestRefineErrorRecovery). Full suite:
526 passed, 1 skipped. Ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:20:51 +02:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
2026-03-20 15:36:06 -07:00
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from headroom.memory.traffic_learner import (
|
|
|
|
|
|
ExtractedPattern,
|
|
|
|
|
|
PatternCategory,
|
|
|
|
|
|
TrafficLearner,
|
2026-04-30 18:05:54 +09:00
|
|
|
|
_bash_binaries_match,
|
|
|
|
|
|
_bash_first_binary,
|
2026-03-20 15:36:06 -07:00
|
|
|
|
_classify_error,
|
2026-04-30 17:26:20 +09:00
|
|
|
|
_commands_related_as_retry,
|
|
|
|
|
|
_drop_contradictions,
|
2026-03-20 15:36:06 -07:00
|
|
|
|
_is_error,
|
2026-04-30 18:05:54 +09:00
|
|
|
|
_levenshtein,
|
2026-04-22 17:00:15 +02:00
|
|
|
|
_load_persisted_patterns_from_sqlite,
|
test(memory): raise patch coverage from 83% to 98% on error_recovery fixes
26 new tests covering:
- TestNormalizeBashForHash — empty string, no-suffix, head/tail strip,
trailing context flags, stderr redirect, chain-boundary truncation
- TestParseIsoTimestamp — None, empty, non-string, invalid format,
naive (assumed UTC), tz-aware preserved
- TestLoadPersistedPatternsTimestamps — reads first_seen_at/last_seen_at
from metadata, falls back to created_at, collision-merges timestamps
and bumps importance to max, handles malformed JSON and non-numeric
importance cells gracefully
- TestBumpPersistsLastSeenAt — verifies _bump_persisted_evidence writes
$.last_seen_at into metadata JSON
- TestHydrateLegacyRow — legacy rows without category, rows with
unknown/invalid category, rows with empty content
- TestCollectAllPatternsTimestamps — in-session re-sighting bumps
last_seen_at past stale persisted timestamp
- TestRefineErrorRecovery (additions) — refine-empties-section skips
recommendation entirely, OSError during re-validation keeps the row,
Read patterns without success_path skip re-validation cleanly
Remaining uncovered lines in patch (4): defensive exception handlers
in _hydrate_persisted_state (sqlite connect OperationalError, asyncio
thread exception, JSONDecodeError on metadata) that require heavy
mocking for marginal value.
91 tests pass, ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:53:51 +02:00
|
|
|
|
_normalize_bash_for_hash,
|
|
|
|
|
|
_parse_iso_timestamp,
|
2026-04-30 17:26:20 +09:00
|
|
|
|
_paths_related_as_typo,
|
2026-04-22 17:00:15 +02:00
|
|
|
|
_patterns_to_recommendations,
|
|
|
|
|
|
_project_for_pattern,
|
fix(memory): collapse and decay error_recovery patterns in MEMORY.md
The Learned: error recovery section was bloating with stale, near-duplicate,
and contradictory entries because the dedup key was the literal rendered
bullet text and there was no TTL or re-validation.
- Normalize the hash key for error_recovery patterns. Read recoveries key
on (basename(error_path), basename(success_path)); Bash recoveries strip
volatile suffixes (| tail -N, 2>&1, etc.) and hash only the primary
command before the first | or &&. Non-error-recovery categories keep
literal-content hashing.
- Stamp first_seen_at / last_seen_at on every pattern; bump both in
_bump_persisted_evidence via json_set. Stored in metadata JSON — no
schema change.
- Refine at render time (error_recovery only): drop rows not re-observed
in 21 days, re-validate Read success paths against the filesystem,
collapse same-error_path-with-multiple-targets into one "use Glob/Grep
first" bullet, rank by evidence_count * 0.5 ** (days/5), cap at 15
bullets.
15 new tests (TestNormalizedHash, TestRefineErrorRecovery). Full suite:
526 passed, 1 skipped. Ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:20:51 +02:00
|
|
|
|
_refine_error_recovery,
|
2026-03-20 15:36:06 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
fix(memory): collapse and decay error_recovery patterns in MEMORY.md
The Learned: error recovery section was bloating with stale, near-duplicate,
and contradictory entries because the dedup key was the literal rendered
bullet text and there was no TTL or re-validation.
- Normalize the hash key for error_recovery patterns. Read recoveries key
on (basename(error_path), basename(success_path)); Bash recoveries strip
volatile suffixes (| tail -N, 2>&1, etc.) and hash only the primary
command before the first | or &&. Non-error-recovery categories keep
literal-content hashing.
- Stamp first_seen_at / last_seen_at on every pattern; bump both in
_bump_persisted_evidence via json_set. Stored in metadata JSON — no
schema change.
- Refine at render time (error_recovery only): drop rows not re-observed
in 21 days, re-validate Read success paths against the filesystem,
collapse same-error_path-with-multiple-targets into one "use Glob/Grep
first" bullet, rank by evidence_count * 0.5 ** (days/5), cap at 15
bullets.
15 new tests (TestNormalizedHash, TestRefineErrorRecovery). Full suite:
526 passed, 1 skipped. Ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:20:51 +02:00
|
|
|
|
UTC = timezone.utc
|
|
|
|
|
|
|
2026-03-20 15:36:06 -07:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
2026-04-30 18:05:54 +09:00
|
|
|
|
assert not _paths_related_as_typo("/src-tauri/src/state.rs", "/src-tauri/src/lib.rs")
|
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):
|
2026-04-30 18:05:54 +09:00
|
|
|
|
assert _commands_related_as_retry("cargo build", "cargo build --release")
|
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]
|
|
|
|
|
|
|
2026-04-30 18:05:54 +09:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-30 17:26:20 +09:00
|
|
|
|
|
2026-03-20 15:36:06 -07: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"},
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-03-20 15:36:06 -07:00
|
|
|
|
|
|
|
|
|
|
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"},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-03-20 15:36:06 -07:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-03-20 15:36:06 -07:00
|
|
|
|
@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"}])
|
|
|
|
|
|
== []
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-20 15:36:06 -07:00
|
|
|
|
@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
|
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
|
|
|
|
|
|
|
2026-05-09 15:45:26 -07:00
|
|
|
|
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
|
|
|
|
|
|
|
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 '[]', "
|
fix(memory): collapse and decay error_recovery patterns in MEMORY.md
The Learned: error recovery section was bloating with stale, near-duplicate,
and contradictory entries because the dedup key was the literal rendered
bullet text and there was no TTL or re-validation.
- Normalize the hash key for error_recovery patterns. Read recoveries key
on (basename(error_path), basename(success_path)); Bash recoveries strip
volatile suffixes (| tail -N, 2>&1, etc.) and hash only the primary
command before the first | or &&. Non-error-recovery categories keep
literal-content hashing.
- Stamp first_seen_at / last_seen_at on every pattern; bump both in
_bump_persisted_evidence via json_set. Stored in metadata JSON — no
schema change.
- Refine at render time (error_recovery only): drop rows not re-observed
in 21 days, re-validate Read success paths against the filesystem,
collapse same-error_path-with-multiple-targets into one "use Glob/Grep
first" bullet, rank by evidence_count * 0.5 ** (days/5), cap at 15
bullets.
15 new tests (TestNormalizedHash, TestRefineErrorRecovery). Full suite:
526 passed, 1 skipped. Ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:20:51 +02:00
|
|
|
|
"importance REAL NOT NULL DEFAULT 0.5, "
|
|
|
|
|
|
"created_at TEXT)"
|
2026-04-22 17:00:15 +02:00
|
|
|
|
)
|
|
|
|
|
|
for i, r in enumerate(rows):
|
|
|
|
|
|
conn.execute(
|
fix(memory): collapse and decay error_recovery patterns in MEMORY.md
The Learned: error recovery section was bloating with stale, near-duplicate,
and contradictory entries because the dedup key was the literal rendered
bullet text and there was no TTL or re-validation.
- Normalize the hash key for error_recovery patterns. Read recoveries key
on (basename(error_path), basename(success_path)); Bash recoveries strip
volatile suffixes (| tail -N, 2>&1, etc.) and hash only the primary
command before the first | or &&. Non-error-recovery categories keep
literal-content hashing.
- Stamp first_seen_at / last_seen_at on every pattern; bump both in
_bump_persisted_evidence via json_set. Stored in metadata JSON — no
schema change.
- Refine at render time (error_recovery only): drop rows not re-observed
in 21 days, re-validate Read success paths against the filesystem,
collapse same-error_path-with-multiple-targets into one "use Glob/Grep
first" bullet, rank by evidence_count * 0.5 ** (days/5), cap at 15
bullets.
15 new tests (TestNormalizedHash, TestRefineErrorRecovery). Full suite:
526 passed, 1 skipped. Ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:20:51 +02:00
|
|
|
|
"INSERT INTO memories "
|
|
|
|
|
|
"(id, content, metadata, entity_refs, importance, created_at) "
|
|
|
|
|
|
"VALUES (?,?,?,?,?,?)",
|
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),
|
fix(memory): collapse and decay error_recovery patterns in MEMORY.md
The Learned: error recovery section was bloating with stale, near-duplicate,
and contradictory entries because the dedup key was the literal rendered
bullet text and there was no TTL or re-validation.
- Normalize the hash key for error_recovery patterns. Read recoveries key
on (basename(error_path), basename(success_path)); Bash recoveries strip
volatile suffixes (| tail -N, 2>&1, etc.) and hash only the primary
command before the first | or &&. Non-error-recovery categories keep
literal-content hashing.
- Stamp first_seen_at / last_seen_at on every pattern; bump both in
_bump_persisted_evidence via json_set. Stored in metadata JSON — no
schema change.
- Refine at render time (error_recovery only): drop rows not re-observed
in 21 days, re-validate Read success paths against the filesystem,
collapse same-error_path-with-multiple-targets into one "use Glob/Grep
first" bullet, rank by evidence_count * 0.5 ** (days/5), cap at 15
bullets.
15 new tests (TestNormalizedHash, TestRefineErrorRecovery). Full suite:
526 passed, 1 skipped. Ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:20:51 +02:00
|
|
|
|
r.get("created_at"),
|
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 '[]', "
|
fix(memory): collapse and decay error_recovery patterns in MEMORY.md
The Learned: error recovery section was bloating with stale, near-duplicate,
and contradictory entries because the dedup key was the literal rendered
bullet text and there was no TTL or re-validation.
- Normalize the hash key for error_recovery patterns. Read recoveries key
on (basename(error_path), basename(success_path)); Bash recoveries strip
volatile suffixes (| tail -N, 2>&1, etc.) and hash only the primary
command before the first | or &&. Non-error-recovery categories keep
literal-content hashing.
- Stamp first_seen_at / last_seen_at on every pattern; bump both in
_bump_persisted_evidence via json_set. Stored in metadata JSON — no
schema change.
- Refine at render time (error_recovery only): drop rows not re-observed
in 21 days, re-validate Read success paths against the filesystem,
collapse same-error_path-with-multiple-targets into one "use Glob/Grep
first" bullet, rank by evidence_count * 0.5 ** (days/5), cap at 15
bullets.
15 new tests (TestNormalizedHash, TestRefineErrorRecovery). Full suite:
526 passed, 1 skipped. Ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:20:51 +02:00
|
|
|
|
"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
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 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)
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
|
|
|
|
|
|
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))
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
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",
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-30 17:08:29 +09:00
|
|
|
|
@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"
|
|
|
|
|
|
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
@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"
|
|
|
|
|
|
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
@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)
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
_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)
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
_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",
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
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 == []
|
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Adds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:44 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 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()
|
fix(memory): collapse and decay error_recovery patterns in MEMORY.md
The Learned: error recovery section was bloating with stale, near-duplicate,
and contradictory entries because the dedup key was the literal rendered
bullet text and there was no TTL or re-validation.
- Normalize the hash key for error_recovery patterns. Read recoveries key
on (basename(error_path), basename(success_path)); Bash recoveries strip
volatile suffixes (| tail -N, 2>&1, etc.) and hash only the primary
command before the first | or &&. Non-error-recovery categories keep
literal-content hashing.
- Stamp first_seen_at / last_seen_at on every pattern; bump both in
_bump_persisted_evidence via json_set. Stored in metadata JSON — no
schema change.
- Refine at render time (error_recovery only): drop rows not re-observed
in 21 days, re-validate Read success paths against the filesystem,
collapse same-error_path-with-multiple-targets into one "use Glob/Grep
first" bullet, rank by evidence_count * 0.5 ** (days/5), cap at 15
bullets.
15 new tests (TestNormalizedHash, TestRefineErrorRecovery). Full suite:
526 passed, 1 skipped. Ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:20:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
test(memory): raise patch coverage from 83% to 98% on error_recovery fixes
26 new tests covering:
- TestNormalizeBashForHash — empty string, no-suffix, head/tail strip,
trailing context flags, stderr redirect, chain-boundary truncation
- TestParseIsoTimestamp — None, empty, non-string, invalid format,
naive (assumed UTC), tz-aware preserved
- TestLoadPersistedPatternsTimestamps — reads first_seen_at/last_seen_at
from metadata, falls back to created_at, collision-merges timestamps
and bumps importance to max, handles malformed JSON and non-numeric
importance cells gracefully
- TestBumpPersistsLastSeenAt — verifies _bump_persisted_evidence writes
$.last_seen_at into metadata JSON
- TestHydrateLegacyRow — legacy rows without category, rows with
unknown/invalid category, rows with empty content
- TestCollectAllPatternsTimestamps — in-session re-sighting bumps
last_seen_at past stale persisted timestamp
- TestRefineErrorRecovery (additions) — refine-empties-section skips
recommendation entirely, OSError during re-validation keeps the row,
Read patterns without success_path skip re-validation cleanly
Remaining uncovered lines in patch (4): defensive exception handlers
in _hydrate_persisted_state (sqlite connect OperationalError, asyncio
thread exception, JSONDecodeError on metadata) that require heavy
mocking for marginal value.
91 tests pass, ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:53:51 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
fix(memory): collapse and decay error_recovery patterns in MEMORY.md
The Learned: error recovery section was bloating with stale, near-duplicate,
and contradictory entries because the dedup key was the literal rendered
bullet text and there was no TTL or re-validation.
- Normalize the hash key for error_recovery patterns. Read recoveries key
on (basename(error_path), basename(success_path)); Bash recoveries strip
volatile suffixes (| tail -N, 2>&1, etc.) and hash only the primary
command before the first | or &&. Non-error-recovery categories keep
literal-content hashing.
- Stamp first_seen_at / last_seen_at on every pattern; bump both in
_bump_persisted_evidence via json_set. Stored in metadata JSON — no
schema change.
- Refine at render time (error_recovery only): drop rows not re-observed
in 21 days, re-validate Read success paths against the filesystem,
collapse same-error_path-with-multiple-targets into one "use Glob/Grep
first" bullet, rank by evidence_count * 0.5 ** (days/5), cap at 15
bullets.
15 new tests (TestNormalizedHash, TestRefineErrorRecovery). Full suite:
526 passed, 1 skipped. Ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:20:51 +02:00
|
|
|
|
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
|
test(memory): raise patch coverage from 83% to 98% on error_recovery fixes
26 new tests covering:
- TestNormalizeBashForHash — empty string, no-suffix, head/tail strip,
trailing context flags, stderr redirect, chain-boundary truncation
- TestParseIsoTimestamp — None, empty, non-string, invalid format,
naive (assumed UTC), tz-aware preserved
- TestLoadPersistedPatternsTimestamps — reads first_seen_at/last_seen_at
from metadata, falls back to created_at, collision-merges timestamps
and bumps importance to max, handles malformed JSON and non-numeric
importance cells gracefully
- TestBumpPersistsLastSeenAt — verifies _bump_persisted_evidence writes
$.last_seen_at into metadata JSON
- TestHydrateLegacyRow — legacy rows without category, rows with
unknown/invalid category, rows with empty content
- TestCollectAllPatternsTimestamps — in-session re-sighting bumps
last_seen_at past stale persisted timestamp
- TestRefineErrorRecovery (additions) — refine-empties-section skips
recommendation entirely, OSError during re-validation keeps the row,
Read patterns without success_path skip re-validation cleanly
Remaining uncovered lines in patch (4): defensive exception handlers
in _hydrate_persisted_state (sqlite connect OperationalError, asyncio
thread exception, JSONDecodeError on metadata) that require heavy
mocking for marginal value.
91 tests pass, ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:53:51 +02:00
|
|
|
|
|
|
|
|
|
|
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"]
|
test(memory): raise patch coverage from 83% to 98% on error_recovery fixes
26 new tests covering:
- TestNormalizeBashForHash — empty string, no-suffix, head/tail strip,
trailing context flags, stderr redirect, chain-boundary truncation
- TestParseIsoTimestamp — None, empty, non-string, invalid format,
naive (assumed UTC), tz-aware preserved
- TestLoadPersistedPatternsTimestamps — reads first_seen_at/last_seen_at
from metadata, falls back to created_at, collision-merges timestamps
and bumps importance to max, handles malformed JSON and non-numeric
importance cells gracefully
- TestBumpPersistsLastSeenAt — verifies _bump_persisted_evidence writes
$.last_seen_at into metadata JSON
- TestHydrateLegacyRow — legacy rows without category, rows with
unknown/invalid category, rows with empty content
- TestCollectAllPatternsTimestamps — in-session re-sighting bumps
last_seen_at past stale persisted timestamp
- TestRefineErrorRecovery (additions) — refine-empties-section skips
recommendation entirely, OSError during re-validation keeps the row,
Read patterns without success_path skip re-validation cleanly
Remaining uncovered lines in patch (4): defensive exception handlers
in _hydrate_persisted_state (sqlite connect OperationalError, asyncio
thread exception, JSONDecodeError on metadata) that require heavy
mocking for marginal value.
91 tests pass, ruff + mypy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:53:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 8–98 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("?")
|