diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 8426e8564..3e745dc97 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -43,6 +43,28 @@ def installed_aelf() -> Sequence[str]: return ("uv", "run", "aelf") +@pytest.fixture +def installed_console_script( + installed_aelf: Sequence[str], +) -> "callable[[str], Sequence[str]]": # type: ignore[name-defined] + """Resolve a sibling aelfrice console script (e.g. `aelf-commit-ingest`). + + Console scripts declared in pyproject.toml all land in the same bin + directory regardless of install method (uv-tool, pipx, venv-pip), so + sibling resolution from `installed_aelf` is safe. The local + `uv run aelf` fallback maps to `uv run ` for parity. + """ + + def _resolve(name: str) -> Sequence[str]: + head = installed_aelf[0] + if head == "uv" and len(installed_aelf) >= 2 and installed_aelf[1] == "run": + return ("uv", "run", name) + sibling = Path(head).with_name(name) + return (str(sibling),) + + return _resolve + + @pytest.fixture def aelf_run( installed_aelf: Sequence[str], diff --git a/tests/e2e/test_hook_inject_roundtrip.py b/tests/e2e/test_hook_inject_roundtrip.py new file mode 100644 index 000000000..35b6aea7a --- /dev/null +++ b/tests/e2e/test_hook_inject_roundtrip.py @@ -0,0 +1,92 @@ +"""E2E scenario #2 (#334): hook -> ingest -> rebuild -> inject roundtrip. + +Exercises the seam the UserPromptSubmit / PreCompact hooks travel: + + pre-seeded store + recent-turn transcript + -> rebuild_v14 (the same code path the hook calls) + -> stdout context block + -> what the next turn would see injected. + +`aelf rebuild --transcript ` is the manual entry point onto +the hook code path (CLI docstring: "same code path as the PreCompact +hook"), so a passing test here means the hook would also produce the +expected block on real input. A regression on any of the three +moving parts (transcript reader, retrieval, block renderer) drops a +locked belief out of the rebuild output and fails this test. +""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Callable + +import pytest + + +pytestmark = pytest.mark.timeout(120) + + +def test_locked_belief_reaches_rebuild_block( + aelf_run: Callable[..., subprocess.CompletedProcess[str]], + tmp_path: Path, +) -> None: + """A locked belief related to a recent transcript turn must appear + in the rebuild block stdout. Failure here means the hook would + inject an empty / stale memory block to the next turn. + """ + distinctive = ( + "Wibble pickling requires the canonical protocol header bytes." + ) + aelf_run("lock", distinctive) + + # the host harness internal transcript shape: type/message/sessionId/ + # timestamp/cwd. The rebuilder accepts this format via + # `--transcript` (read_recent_turns_claude_transcript). + transcript = tmp_path / "claude-session.jsonl" + turn = { + "type": "user", + "message": { + "role": "user", + "content": "How does wibble pickling handle the header?", + }, + "sessionId": "e2e-hook-roundtrip", + "timestamp": "2026-05-02T22:00:00Z", + "cwd": str(tmp_path), + } + transcript.write_text(json.dumps(turn) + "\n") + + result = aelf_run("rebuild", "--transcript", str(transcript)) + assert result.returncode == 0, ( + f"rebuild exited {result.returncode}; stderr: {result.stderr!r}" + ) + + block = result.stdout + # The locked belief carries the distinctive token. If retrieval + + # pack-to-budget are wired, "wibble" lands in the block. The exact + # rendering format is not coupled to here — only the content. + assert "wibble" in block.lower(), ( + f"expected locked belief in rebuild block; got:\n{block!r}" + ) + + +def test_empty_store_rebuild_completes_cleanly( + aelf_run: Callable[..., subprocess.CompletedProcess[str]], + tmp_path: Path, +) -> None: + """Rebuild against an empty store + empty transcript must exit 0. + + Guards the no-op contract: the hook fires every turn and must + never wedge an empty session. If the rebuilder raised when the + transcript / store had no usable input, every fresh project + would crash on first prompt. + """ + transcript = tmp_path / "empty-session.jsonl" + transcript.write_text("") + result = aelf_run( + "rebuild", "--transcript", str(transcript), check=False + ) + assert result.returncode == 0, ( + f"empty-input rebuild exited {result.returncode}; " + f"stderr: {result.stderr!r}" + ) diff --git a/tests/e2e/test_source_type_discrimination.py b/tests/e2e/test_source_type_discrimination.py new file mode 100644 index 000000000..736bde412 --- /dev/null +++ b/tests/e2e/test_source_type_discrimination.py @@ -0,0 +1,187 @@ +"""E2E scenario #3 (#334): ingest-source discrimination across paths. + +Catches the #190 R1 class of regression: an INGEST_SOURCE_* constant +is defined in `aelfrice.models`, exported, but never written to the +store by the path that should be writing it. Unit tests are green at +every step because each module is correct in isolation; the contract +drift only shows up after a real cross-module ingest. + +The discriminating signal on a single-shot first ingest lives in +`ingest_log.source_kind` (the v2.0 #205 source-of-truth log written +by `record_ingest`). The corroboration table's `source_type` is the +same wiring expressed on the re-assertion edge, but it only fires on +content_hash hits, which a one-shot test can't reliably trigger. + +Each path is exercised through its real entry point and the resulting +row in `ingest_log` is read back via stdlib `sqlite3` — no in-process +imports of the aelfrice package, per the e2e boundary rule: + + cli_remember -> `aelf lock ` + filesystem -> `aelf ingest-transcript ` (source_path + distinguishes transcript from other filesystem + ingest paths within `ingest_log`) + git -> `aelf-commit-ingest` (PostToolUse hook entry + point) fed a synthetic Bash-tool payload + +If any of these console-script paths fails to write the expected +`source_kind` to the store, this test fails — independent of whether +the in-process unit tests for that module still pass. +""" +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +from pathlib import Path +from typing import Callable, Sequence + +import pytest + + +pytestmark = pytest.mark.timeout(120) + + +def _read_ingest_source_kinds(db_path: Path) -> set[str]: + """Return the distinct source_kind values in `ingest_log`. + + Stdlib sqlite3 read-only access; no aelfrice imports. + """ + if not db_path.exists(): + return set() + uri = f"file:{db_path}?mode=ro" + with sqlite3.connect(uri, uri=True) as conn: + rows = conn.execute( + "SELECT DISTINCT source_kind FROM ingest_log" + ).fetchall() + return {str(r[0]) for r in rows} + + +def _read_transcript_source_paths(db_path: Path) -> set[str]: + """Distinct source_path values for filesystem-kind ingest_log rows. + + Lets the test prove that `aelf ingest-transcript` lands the + transcript label, not just the generic filesystem source_kind. + """ + if not db_path.exists(): + return set() + uri = f"file:{db_path}?mode=ro" + with sqlite3.connect(uri, uri=True) as conn: + rows = conn.execute( + "SELECT DISTINCT source_path FROM ingest_log " + "WHERE source_kind = 'filesystem'" + ).fetchall() + return {str(r[0]) for r in rows if r[0] is not None} + + +def test_three_paths_record_distinct_source_types( + aelf_run: Callable[..., subprocess.CompletedProcess[str]], + installed_console_script: Callable[[str], Sequence[str]], + ephemeral_db: Path, + tiny_project: Path, + tmp_path: Path, +) -> None: + """All three first-class ingest paths must record their declared + `source_type` in `belief_corroborations`. Asserts the union of + observed source_types contains the three declared constants. + """ + # Path 1: cli_remember via `aelf lock`. + aelf_run("lock", "Quokkas calibrate the knob carefully on Tuesdays.") + + # Path 2: transcript_ingest via `aelf ingest-transcript`. A + # transcript-logger turns.jsonl line is the simplest accepted shape. + transcript = tmp_path / "turns.jsonl" + transcript.write_text( + json.dumps( + { + "role": "user", + "text": "The aardvark counter resets at midnight.", + "session_id": "e2e-source-type", + "ts": "2026-05-02T22:00:00Z", + } + ) + + "\n" + ) + aelf_run("ingest-transcript", str(transcript)) + + # Path 3: commit_ingest via the PostToolUse hook entry point. The + # hook reads a the host harness Bash tool payload from stdin and runs + # `git log -1 --format=%B ` against `cwd` to fetch the body. + # `tiny_project` is a real git repo, so we land a fresh commit on + # it and shape a payload mirroring what the host harness emits. + # The triple extractor needs at least one (subject, relation, object) + # match in the message body, otherwise the hook short-circuits before + # opening the store. Pattern: ` ` with a permitted + # relation verb. "supports" is in the relation bank. + commit_msg = "feat: the yokozuna parser supports nested meridian keys" + git_env = { + **os.environ, + "GIT_AUTHOR_NAME": "tiny-project", + "GIT_AUTHOR_EMAIL": "tiny@example.invalid", + "GIT_COMMITTER_NAME": "tiny-project", + "GIT_COMMITTER_EMAIL": "tiny@example.invalid", + } + (tiny_project / "MARKER").write_text("present\n") + subprocess.run( # noqa: S603, S607 + ["git", "add", "MARKER"], cwd=tiny_project, env=git_env, check=True, + capture_output=True, + ) + commit_proc = subprocess.run( # noqa: S603, S607 + ["git", "commit", "-q", "-m", commit_msg], + cwd=tiny_project, env=git_env, check=True, capture_output=True, text=True, + ) + short_hash_proc = subprocess.run( # noqa: S603, S607 + ["git", "rev-parse", "--short=12", "HEAD"], + cwd=tiny_project, env=git_env, check=True, capture_output=True, text=True, + ) + short_hash = short_hash_proc.stdout.strip() + # Synthesise the bracket-prefix line `[branch hash] subject` the + # hook expects in tool_response.stdout. Real the host harness prints + # this line after every successful `git commit`. + fake_stdout = f"[main {short_hash}] {commit_msg}\n" + payload = { + "tool_name": "Bash", + "tool_input": {"command": f"git commit -m {commit_msg!r}"}, + "tool_response": {"stdout": fake_stdout, "isError": False}, + "cwd": str(tiny_project), + } + + hook_argv = installed_console_script("aelf-commit-ingest") + env = os.environ.copy() + env["AELFRICE_DB"] = str(ephemeral_db) + hook_proc = subprocess.run( # noqa: S603 + list(hook_argv), + input=json.dumps(payload), + env=env, + capture_output=True, + text=True, + timeout=60, + check=True, # hook contract is exit 0 even on internal errors + ) + # Hook is non-blocking by contract; surface stderr if it tracebacked. + assert "Traceback" not in hook_proc.stderr, hook_proc.stderr + # commit subprocess succeeded; sanity-check stdout had the bracket line + assert short_hash in commit_proc.stdout or commit_proc.returncode == 0 + + observed_kinds = _read_ingest_source_kinds(ephemeral_db) + + # The three constants are declared in src/aelfrice/models.py: + # INGEST_SOURCE_CLI_REMEMBER, INGEST_SOURCE_FILESYSTEM, INGEST_SOURCE_GIT. + # Hard-code the literal values here so a rename-without-call-site-update + # on either side fails this test loudly — that is the #190 R1 bug class. + expected_kinds = {"cli_remember", "filesystem", "git"} + missing = expected_kinds - observed_kinds + assert not missing, ( + f"missing source_kind rows in ingest_log: {sorted(missing)}; " + f"observed: {sorted(observed_kinds)}" + ) + + # Transcript ingest must additionally land its source_label so it is + # distinguishable from other filesystem-kind ingest paths (e.g. raw + # filesystem scanner). The default label from `aelf ingest-transcript` + # is "transcript". + transcript_paths = _read_transcript_source_paths(ephemeral_db) + assert "transcript" in transcript_paths, ( + f"expected source_path='transcript' for filesystem-kind ingest; " + f"got {sorted(transcript_paths)}" + )