---
pr: 102
title: E2E coverage for 4 hybrid-memory scenarios
---

## Что сделано

Создан `tests/test_memory_tools_e2e.py` с 4 E2E сценариями, покрывающими гибридную
память (keyword + semantic):

1. **zero-config keyword-only** — поиск работает без `OPENAI_BASE_URL` (semantic
   возвращает `[]`), keyword через ripgrep находит файл.
2. **full hybrid** — semantic находит по точному термину и синониму (через
   embedding), keyword находит по точному слову.
3. **fallback при падении OpenRouter** — при невалидном `OPENAI_API_KEY` semantic
   возвращает `[]` (401 не retryable → embedder returns None), keyword работает.
4. **удаление файла** — после удаления .md + reindex, ни semantic, ни keyword не
   находят удалённый файл.

Все тесты маркированы `@pytest.mark.skipif(not RUN_LIVE)` — запускаются только с
`RUN_LIVE=1`. Без env — 4 skipped.

Вспомогательные функции:
- `_rg_search(pattern, dir)` — keyword через ripgrep (subprocess), fallback на
  `/root/.cache/opencode/bin/rg` если нет в PATH.
- `_semantic_search(query, index_dir, env)` — semantic через `python3 -m
  src.memory search --json` (subprocess), с передачей кастомного env.
- `_reindex(memory_dir, index_dir)` — reindex + poll `reindex.log` до `done:`.
- `_make_memory_file(dir, term, synonym)` — tmp .md с frontmatter + уникальным
  термином `zzuniqtestterm42` + опциональным синонимом.

## Почему

Фаза 1 (PR #100) сделала Python backend safe (lazy-init, return None). Фаза 2
(PR #101) создала 5 TS tools. Нужна E2E гарантия что keyword fallback работает
когда semantic недоступен — без E2E регрессии: lazy-init поломался → все tools
падают; fallback сломался → semantic-up → memory-down.

Подробное обоснование в `technical/memory-keyword-vs-semantic-decision.md` —
гибрид выбран вместо semantic-only именно из-за стабильности keyword path.

## Pending

—

## Watch out

- Тесты требуют `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_EMBEDDING_MODEL` в env
  (реальный OpenRouter, не mock). Запуск: `RUN_LIVE=1 uv run pytest
  tests/test_memory_tools_e2e.py -v --no-cov`.
- Сценарий 4 (deletion) требует dummy-файл в memory_dir — иначе `run_index`
  возвращает по `if not md_files` без обновления `index.json`, и удалённая запись
  остаётся в индексе. Добавлен `_make_dummy_file`.
- `rg` может быть не в PATH — `_rg_bin()` пробует `shutil.which` затем
  `/root/.cache/opencode/bin/rg`, pytest.skip если нет.
- Timeout: сценарий 3 — 300с (хотя 401 не retryable и embedder returns None
  быстро, на всякий случай), сценарии 2/4 — 180с.
- Ruff: `subprocess.run` требует `check=False` (PLW1510) — добавлено явно.