Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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],
Expand Down
92 changes: 92 additions & 0 deletions tests/e2e/test_hook_inject_roundtrip.py
Original file line number Diff line number Diff line change
@@ -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 <jsonl>` 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}"
)
187 changes: 187 additions & 0 deletions tests/e2e/test_source_type_discrimination.py
Original file line number Diff line number Diff line change
@@ -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 <statement>`
filesystem -> `aelf ingest-transcript <jsonl>` (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 <hash>` 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: `<NP> <verb> <NP>` 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)}"
)
Loading