ReMe/tests/integration/test_reme_e2e.py
imrewce 354837f9af
feat(proactive): separate proactive refresh from auto dream (#488)
* refractor(proactive): upgrade proactive feature with disentangled job and steps

* refactor(proactive): apply audit fixes

- rename read-side job 'proactive' -> 'proactive_read' (less confusing vs the refresh pipeline)
- drop dedicated agent_wrapper.proactive; extraction reuses the default wrapper
- simplify schema: remove unused ProactiveExtractOutput/TopicUpdate, drop resource_paths
- extract no longer scans resource/ directly (daily notes already carry resource content)
- update tests and docs accordingly

* feat(proactive): strict extract-output gate and prompt total budget

- parse_extract_reply now requires a contract section (follow_ups/extends/updates
  as a list); non-empty replies with misspelled section names trigger the
  existing one-shot retry instead of silently checkpointing changed files
- pack_paths gains max_total_chars; extract packs newest daily material first,
  keeps the first file on overflow, and records omitted files in a trailer
  (default budget 300000 chars, configurable via max_total_chars)
- tests: schema gate unit, schema-error retry e2e, budget unit + e2e

* feat(proactive): add scenario-card plan step and generative agenda step

* feat(proactive): digest-personal profile personalization and leaner LLM contract

- extract/plan/agenda now draw a user profile block from <digest_dir>/personal/*.md
  (frontmatter description + body excerpt, per-file budget, profile.md fallback)
- all daily access honours the configured daily_dir (prompt paths parameterized,
  config-driven fallbacks) so workspaces using e.g. memory/ work unchanged
- schema trim: drop dead fields errors/material_paths, carry_forward_all -> count
- shrink LLM output contract: new topics emit title/reason/confidence/paths only;
  keywords removed end-to-end, evidence derived from paths[0] (updates keep it)

* fix(proactive): skip checkpoint when extract reply stays unusable after retry

Two consecutive unparseable replies now short-circuit the round without
checkpointing, so the same material is retried next round instead of being
silently consumed (closes the residual audit #1 gap: the structural gate
detected schema-wrong output but a double failure still checkpointed).

* fix(proactive): replace running bool with reference-counted job activity tracker for the idle gate

* refactor(proactive): remove job activity tracking and idle gate, restore job tree to upstream

* fix(proactive): address second audit round (readonly reader, mtime checkpoint, wider fallbacks, profile containment, horizon content, expiry boundary)

* refactor(dream): strip interests.yaml ownership from dream, proactive is now the sole writer

* refactor(dream): separate proactive topic generation

* ci: update renamed auto dream smoke test

* fix(proactive): complete refresh migration and docs

---------

Co-authored-by: jinli.yl <jinli.yl@alibaba-inc.com>
2026-09-07 17:23:37 +08:00

214 lines
8.9 KiB
Python

"""End-to-end integration test for the full ReMe memory loop.
Unlike the per-job tests in this directory (which each exercise one job in
isolation), this drives the *whole* lifecycle through the public
``ReMe.run_job`` surface, in the order a real deployment runs it:
1. provision — ``auto_memory`` : a conversation becomes a daily note
2. provision — ``auto_memory`` : a follow-up updates the same note
3. consolidate — ``auto_dream`` : today's daily notes become digest nodes
4. recall — ``reindex`` + ``search`` : a distinctive fact is retrievable
5. proactive — ``proactive`` : surfaced interest topics are readable
The point is to prove the stages *compose*: a fact spoken in step 1 must
survive provisioning, consolidation, and indexing, and still come back out
of ``search`` in step 4. Assertions are deliberately lenient (``>= N`` hit
counts, "any stage that carried the fact") so the test asserts the loop is
intact without pinning the LLM's exact wording.
Search runs BM25-only (the default ``file_store`` leaves ``embedding_store``
empty), so this needs LLM_API_KEY but not an embedding key. Requires
LLM_API_KEY (and optionally LLM_BASE_URL / LLM_MODEL_NAME) in the
environment or a .env file at the repo root. Hits the real LLM API.
"""
import asyncio
import sys
from pathlib import Path
INTEGRATION_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(INTEGRATION_DIR))
# pylint: disable=wrong-import-position
from _workspace_fixture import workspace_env # noqa: E402
SESSION_ID = "project-meridian-sync"
# A distinctive, self-contained topic — invented proper nouns ("Meridian",
# "WebTransport over QUIC") so a later search hit is unambiguously traceable
# back to this conversation rather than to model priors.
_TURN_1 = [
{
"name": "user",
"role": "user",
"content": ("记一下 Project Meridian 的架构决定:实时协同改用 CRDT," "后端选 Yjs。今天 2026-06-20 定的。"),
},
{
"name": "assistant",
"role": "assistant",
"content": "好的,已记录:Project Meridian 实时协同采用 CRDT,后端 Yjs,2026-06-20 决定。",
},
{
"name": "user",
"role": "user",
"content": (
"动机是旧的 OT (operational transform) 方案在离线编辑合并时冲突太多,"
"CRDT 的最终一致性更适合多端离线场景。"
),
},
]
_TURN_2 = [
{
"name": "user",
"role": "user",
"content": (
"Meridian 传输层更新:放弃 WebSocket,改走 WebTransport over QUIC。"
"原因是 WebSocket 的 head-of-line blocking 在弱网下让 CRDT update 批量延迟。"
),
},
{
"name": "assistant",
"role": "assistant",
"content": "明白,传输层从 WebSocket 切到 WebTransport (QUIC),解决队头阻塞。",
},
{
"name": "user",
"role": "user",
"content": "下一步:2026-06-27 前完成 WebTransport 的 fallback 到 WebSocket 的降级逻辑。",
},
]
# Facts seeded across the two conversation turns; the loop must carry enough
# of them through to the final search.
_TOPIC_FACTS = ("Meridian", "CRDT", "Yjs", "WebTransport", "QUIC", "WebSocket")
def _read(p: Path) -> str:
return p.read_text(encoding="utf-8")
def _all_workspace_text(env) -> str:
"""Concatenate every daily note + digest node — the consolidated memory."""
parts = [_read(p) for p in env.daily_notes()]
parts += [_read(p) for p in env.digest_files()]
return "\n\n".join(parts)
async def _run_loop(env, reme) -> None:
"""The 5-stage e2e body, factored out so a local driver can reuse it
against a non-temp workspace (see run_reme_e2e_local.py)."""
today = env.today
print("\n" + "=" * 70)
print("[setup] workspace_root =", env.workspace_dir)
print("[setup] today =", today)
print("=" * 70)
# ---- 1. provision: CREATE a daily note from turn 1 ----------
create = await reme.run_job(
"auto_memory",
messages=_TURN_1,
session_id=SESSION_ID,
)
assert create.success is True, f"auto_memory CREATE failed: {create.answer!r}"
meta = create.metadata or {}
assert meta.get("created") is True, f"expected created=True, got {meta!r}"
note_rel = f"daily/{today}/{SESSION_ID}.md"
assert meta.get("path") == note_rel, f"unexpected note path: {meta!r}"
note_path = env.workspace_dir / note_rel
assert note_path.is_file(), f"daily note not written: {note_path}"
after_create = _read(note_path)
print(f"\n[1/5 provision-create] {note_path} ({len(after_create)} bytes)\n{after_create}")
for fact in ("CRDT", "Yjs"):
assert fact in after_create, f"CREATE dropped fact {fact!r}\n{after_create}"
# ---- 2. provision: UPDATE the same note from turn 2 ---------
update = await reme.run_job(
"auto_memory",
messages=_TURN_2,
session_id=SESSION_ID,
)
assert update.success is True, f"auto_memory UPDATE failed: {update.answer!r}"
umeta = update.metadata or {}
assert umeta.get("created") is False, f"expected created=False on UPDATE, got {umeta!r}"
assert umeta.get("path") == note_rel, f"UPDATE wrote a different note: {umeta!r}"
after_update = _read(note_path)
print(f"\n[2/5 provision-update] {note_path} ({len(after_update)} bytes)\n{after_update}")
# old facts survive, new facts land
assert "CRDT" in after_update, f"UPDATE dropped pre-existing fact\n{after_update}"
new_hits = [f for f in ("WebTransport", "QUIC", "WebSocket") if f in after_update]
print(f"[2/5] new transport facts landed: {new_hits}")
assert len(new_hits) >= 2, f"UPDATE only landed {new_hits!r}\n{after_update}"
# ---- 3. consolidate: dream today's daily notes into digest --
dream = await reme.run_job(
"auto_dream",
date=today,
hint="Integration e2e: preserve Project Meridian CRDT/Yjs/WebTransport facts.",
)
assert dream.success is True, f"auto_dream failed: {dream.answer!r}\n{dream.metadata!r}"
dmeta = (dream.metadata or {}).get("dream") or {}
print(f"\n[3/5 consolidate] dream summary: {dmeta!r}")
assert dmeta.get("date") == today, f"dream ran for wrong date: {dmeta!r}"
assert dmeta.get("files_changed", 0) >= 1, f"dream changed no files: {dmeta!r}"
digest_paths = env.digest_files()
assert digest_paths, "consolidation produced no digest nodes"
consolidated = _all_workspace_text(env)
carried = [f for f in _TOPIC_FACTS if f in consolidated]
print(f"[3/5] facts present after consolidation: {carried}")
print(f"[3/5] digest nodes: {[str(p.relative_to(env.workspace_dir)) for p in digest_paths]}")
assert len(carried) >= 3, f"consolidation lost the topic; only {carried!r} survived"
# ---- 4. recall: rebuild derived search indexes, then search -----------
reindex = await reme.run_job("reindex")
assert reindex.success is True, f"reindex failed: {reindex.answer!r}"
hit_facts: list[str] = []
searched: list[str] = []
for query in ("Project Meridian 实时协同传输层", "CRDT Yjs WebTransport"):
result = await reme.run_job("search", query=query, limit=5)
assert result.success is True, f"search failed for {query!r}: {result.answer!r}"
answer = result.answer or ""
results_meta = (result.metadata or {}).get("results") or []
print(
f"\n[4/5 recall] query={query!r} -> {len(results_meta)} hit(s)\n" f"{answer[:1500]}",
)
searched.append(query)
hit_facts += [f for f in _TOPIC_FACTS if f in answer]
hit_facts = sorted(set(hit_facts))
print(f"[4/5] facts recalled via search across {searched}: {hit_facts}")
assert hit_facts, (
"search recalled none of the seeded facts — the provision->" "consolidate->index->search loop is broken"
)
# ---- 5. proactive: dream does not produce proactive interests --
proactive = await reme.run_job("proactive_read", date=today, include_content=True)
assert proactive.success is True, f"proactive failed: {proactive.answer!r}"
pmeta = proactive.metadata or {}
assert pmeta.get("path") == f"daily/{today}/interests.yaml", f"unexpected interests path: {pmeta!r}"
assert pmeta.get("skipped") is True, f"proactive unexpectedly found dream-produced topics: {pmeta!r}"
assert pmeta.get("topics") == [], f"proactive unexpectedly returned dream-produced topics: {pmeta!r}"
print("\n" + "=" * 70)
print("test_reme_e2e_full_loop passed")
print("=" * 70)
def test_reme_e2e_full_loop():
"""provision -> consolidate -> recall, end to end, via the public job API."""
async def run():
with workspace_env() as env:
reme = await env.make_reme()
try:
await _run_loop(env, reme)
finally:
await env.close_all()
asyncio.run(run())
if __name__ == "__main__":
print("=== ReMe end-to-end integration test ===")
test_reme_e2e_full_loop()
print("\nIntegration test passed!")