ReMe/tests/unit/test_proactive_refresh.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

1986 lines
81 KiB
Python

# pylint: disable=too-many-lines
"""Unit tests for the proactive refresh chain (PROACTIVE_SPEC.md A8 mapping).
Coverage: F1 contracts, F2 discovery, F3 isolation, F4 budget/timeout,
F5 read extensions, plus the M2 extends branch and semantic dedup.
"""
import asyncio
import datetime as dt
import os
import tempfile
import time
from pathlib import Path
from unittest.mock import patch
import yaml
from agentscope.model import ChatModelBase
from reme.components.agent_wrapper.base_agent_wrapper import BaseAgentWrapper
from reme.components.application_context import ApplicationContext
from reme.components.file_catalog import BaseFileCatalog
from reme.components.file_store import BaseFileStore
from reme.components.runtime_context import RuntimeContext
from reme.schema import FileNode, ProactiveTopic
from reme.steps.evolve.dream.extract import DreamExtractStep
from reme.steps.evolve.dream.utils import pack_paths
from reme.steps.evolve.proactive.agenda import ProactiveAgendaStep
from reme.steps.evolve.proactive.extract import ProactiveExtractStep
from reme.steps.evolve.proactive.finish import ProactiveFinishStep
from reme.steps.evolve.proactive.plan import ProactivePlanStep
from reme.steps.evolve.proactive.proactive import ProactiveStep
from reme.steps.evolve.proactive.topics import ProactiveTopicsStep
from reme.steps.evolve.proactive.utils import (
load_carry_forward,
load_personal_profile_block,
load_state,
load_yaml_topics,
normalize_topic,
parse_interests_topics,
parse_extract_reply,
scan_material_daily,
strip_frontmatter,
topic_id,
trim_state_file,
)
DAY = "2026-08-13"
def _touch(path: Path, text: str = "x") -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
class _Catalog(BaseFileCatalog):
"""In-memory catalog stub recording every mutation."""
def __init__(self, nodes=None):
super().__init__()
self.nodes = list(nodes or [])
self.upserts = []
self.dumps = 0
self.deleted = []
async def upsert(self, nodes):
self.upserts.extend(nodes)
known = {n.path: n for n in self.nodes}
for node in nodes:
known[node.path] = node
self.nodes = list(known.values())
async def delete(self, path):
paths = path if isinstance(path, list) else [path]
self.deleted.extend(paths)
drop = set(paths)
self.nodes = [n for n in self.nodes if n.path not in drop]
async def get_nodes(self, paths=None):
if paths is None:
return list(self.nodes)
wanted = set(paths)
return [n for n in self.nodes if n.path in wanted]
async def dump(self):
self.dumps += 1
class _FileStore(BaseFileStore):
def __init__(self, workspace: Path):
super().__init__()
self._workspace_path = workspace
@property
def workspace_path(self) -> Path:
return self._workspace_path
async def upsert(self, files):
return None
async def delete(self, path):
return None
async def clear(self):
return None
async def get_nodes(self, paths=None):
return []
async def get_outlinks(self, path, scope=None):
return []
async def get_inlinks(self, path, scope=None):
return []
async def vector_search(self, query, limit, search_filter):
return []
async def keyword_search(self, query, limit, search_filter):
return []
class _AgentWrapper(BaseAgentWrapper):
"""Deterministic LLM stub with scripted replies and call accounting."""
def __init__(self, replies=None, delay: float = 0.0):
super().__init__()
self.replies = list(replies or [])
self.delay = delay
self.calls = 0
self.last_inputs = []
self.last_system_prompts = []
async def reply(self, inputs, **kwargs):
self.calls += 1
self.last_inputs.append(inputs)
self.last_system_prompts.append(str(kwargs.get("system_prompt") or ""))
item = self.replies.pop(0) if self.replies else ""
if isinstance(item, tuple):
text, delay = item
else:
text, delay = item, self.delay
if delay:
await asyncio.sleep(delay)
return {
"session_id": "stub",
"result": text,
"last_message": {"content": [{"type": "text", "text": text}]},
}
class _DummyModel(ChatModelBase):
"""Placeholder model satisfying dream extract's llm_available check."""
def __init__(self):
pass
class _FakeEmbedding:
"""Scripted embedding oracle: exact text -> vector, else orthogonal default.
Declares ``kwargs``/``dimensions`` so the topics step can fingerprint the
"model" against ``known_threshold_calibrated_for`` (defaults match).
"""
def __init__(self, vectors: dict[str, list[float]], model: str = "text-embedding-v4", dimensions: int = 1024):
self.vectors = vectors
self.calls = 0
self.kwargs = {"model": model}
self.dimensions = dimensions
async def __call__(self, inputs, **kwargs):
self.calls += 1
return [list(self.vectors.get(text, [0.0, 1.0])) for text in inputs]
def _reply(follow_ups=None, extends=None, updates=None) -> str:
doc = {}
if follow_ups is not None:
doc["follow_ups"] = follow_ups
if extends is not None:
doc["extends"] = extends
if updates is not None:
doc["updates"] = updates
body = yaml.safe_dump(doc, allow_unicode=True, sort_keys=False)
return f"```yaml\n{body}```"
def _topic(title, reason="because", confidence=0.7, paths=None, evidence=None):
doc = {
"title": title,
"reason": reason,
"confidence": confidence,
"paths": paths or [],
}
if evidence:
doc["evidence"] = evidence
return doc
def _state_topic(title, day=DAY, kind="follow_up", confidence=0.8, topic_id_value=None, status="open"):
return {
"id": topic_id_value or topic_id(title),
"title": title,
"reason": "seeded",
"kind": kind,
"confidence": confidence,
"status": status,
"first_seen": day,
"last_evidence_at": day,
"evidence": "",
"paths": [],
}
def _write_state(ws, budget=None, exposure=None, open_topics=None, resolved=None, omit_open_topics=False):
data = {"version": 1}
if budget is not None:
data["budget"] = budget
if exposure is not None:
data["exposure"] = exposure
if not omit_open_topics:
data["open_topics"] = open_topics or []
if resolved is not None:
data["resolved"] = resolved
_touch(ws / "daily" / "_proactive.yaml", yaml.safe_dump(data, allow_unicode=True, sort_keys=False))
def _read_state(ws) -> dict:
return yaml.safe_load((ws / "daily" / "_proactive.yaml").read_text(encoding="utf-8"))
def _interests(ws, day=DAY) -> Path:
return ws / "daily" / day / "interests.yaml"
def _write_interests_v1(ws, day, topics_yaml: list[dict]):
payload = {"date": day, "topic_count": len(topics_yaml), "topics": topics_yaml}
_touch(_interests(ws, day), yaml.safe_dump(payload, allow_unicode=True, sort_keys=False))
def _write_interests_v2(ws, day, topics: list[dict], push=True, skip_reason=""):
payload = {
"version": 2,
"date": day,
"generated_at": f"{day}T09:00:00+08:00",
"push": push,
"skip_reason": skip_reason,
"topics": topics,
}
_touch(_interests(ws, day), yaml.safe_dump(payload, allow_unicode=True, sort_keys=False))
async def _run_chain(
ws,
replies=None,
*,
wrapper=None,
catalog=None,
app=None,
extract_kwargs=None,
topics_kwargs=None,
extra_ctx=None,
context=None,
):
app = app or ApplicationContext(workspace_dir=str(ws))
wrapper = wrapper or _AgentWrapper(replies or [])
catalog = catalog or _Catalog()
context = context or RuntimeContext(
date=DAY,
file_catalog=catalog,
file_store=_FileStore(ws),
agent_wrapper=wrapper,
**(extra_ctx or {}),
)
extract = ProactiveExtractStep(app_context=app, **(extract_kwargs or {}))
resp_extract = await extract(context)
topics = ProactiveTopicsStep(app_context=app, **(topics_kwargs or {}))
resp_topics = await topics(context)
finish = ProactiveFinishStep(app_context=app)
resp_finish = await finish(context)
return app, wrapper, catalog, context, (resp_extract, resp_topics, resp_finish)
def test_known_threshold_model_binding(tmp_path):
"""known_threshold is model-bound (v5.1): fingerprint mismatch degrades.
The oracle maps every text to the same vector (sim 1.0 >= known_threshold),
but the "model" differs from the calibration, so the semantic gate must be
disabled: a paraphrase survives while an exact normalize duplicate of a
historical interests title still drops.
"""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "notes")
_write_state(ws, open_topics=[_state_topic("检索引用质量评测")])
_write_interests_v1(ws, "2026-08-12", [{"title": "向量数据库选型", "reason": "done"}])
embedder = _FakeEmbedding({}, model="some-other-model-v9")
reply = _reply(
follow_ups=[
_topic("检索引用可信度的评测方法", paths=[f"daily/{DAY}/session.md"]),
_topic("向量数据库选型", paths=[f"daily/{DAY}/session.md"]),
],
)
_, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": embedder})
state = context.get("proactive")
assert state["dropped_known"] == 0 # semantic gate disabled by mismatch
assert state["dropped_duplicate"] == 1 # exact compare still active
titles = [t["title"] for t in _read_state(ws)["open_topics"]]
assert "检索引用可信度的评测方法" in titles
assert "向量数据库选型" not in titles
asyncio.run(run())
def test_overage_remention_restarts(tmp_path):
"""Over-age open topics re-mentioned today restart instead of vanishing.
Same-id candidate refreshes first_seen so trim keeps the topic (unfinished
business must keep being re-executed). After the restart the revived title
is back in the comparison set, so a paraphrase of it drops as known.
A dying topic with NO same-id candidate stays excluded from comparison:
its paraphrase re-mention survives and opens a fresh topic.
"""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "notes")
old_day = "2026-07-28" # 16 days before DAY, over-age at carry_forward_days=14
_write_state(
ws,
open_topics=[_state_topic("旧任务确认", day=old_day), _state_topic("遗留事项", day=old_day)],
)
# Discriminative oracle: only the legacy-task / its-supplement pair scores sim 1.0;
# every other pair is orthogonal. If the dying title leaked into the
# comparison set, the paraphrase below would drop as known.
embedder = _FakeEmbedding({"遗留事项的补充。because": [1.0, 0.0], "遗留事项。seeded": [1.0, 0.0]})
reply = _reply(
follow_ups=[
_topic("旧任务确认", paths=[f"daily/{DAY}/session.md"]),
_topic("旧任务确认的后续安排", paths=[f"daily/{DAY}/session.md"]),
_topic("遗留事项的补充", paths=[f"daily/{DAY}/session.md"]),
],
)
_, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": embedder})
state = context.get("proactive")
assert state["dropped_known"] == 1 # paraphrase of the REVIVED topic (default sim 1.0 vs it)
topics = {t["id"]: t for t in _read_state(ws)["open_topics"]}
assert topics[topic_id("旧任务确认")]["first_seen"] == DAY # restarted, not trimmed
assert topic_id("旧任务确认的后续安排") not in topics # known vs revived topic
assert topic_id("遗留事项的补充") in topics # dying title excluded from comparison
assert topic_id("遗留事项") not in topics # over-age, never re-mentioned -> trimmed
asyncio.run(run())
def test_update_evidence_anchor(tmp_path):
"""v5.2: action=update must anchor this round's new material (hard check).
An update citing a file outside the round's changed material degrades to
keep (no evidence rewrite, no freshness refresh); an update citing a
changed file applies, and last_evidence_at is parsed from the evidence
path date instead of defaulting to today.
"""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "today notes")
_touch(ws / "daily" / "2026-08-12" / "old-note.md", "yesterday notes")
_write_state(
ws,
open_topics=[_state_topic("证据改挂主题"), _state_topic("昨日证据主题")],
)
reply = _reply(
updates=[
{"id": topic_id("证据改挂主题"), "action": "update", "evidence": "daily/2026-08-11/absent.md"},
{
"id": topic_id("昨日证据主题"),
"action": "update",
"evidence": "daily/2026-08-12/old-note.md#L5",
"confidence": 0.9,
},
],
)
_, _, _, context, _ = await _run_chain(ws, [reply])
state = context.get("proactive")
assert state["updates_applied"] == 1
topics = {t["id"]: t for t in _read_state(ws)["open_topics"]}
rejected = topics[topic_id("证据改挂主题")]
assert rejected["last_evidence_at"] == DAY # untouched, degraded to keep
assert rejected["evidence"] != "daily/2026-08-11/absent.md"
applied = topics[topic_id("昨日证据主题")]
assert applied["last_evidence_at"] == "2026-08-12" # parsed from evidence path
assert applied["evidence"] == "daily/2026-08-12/old-note.md#L5"
assert applied["confidence"] == 0.9
asyncio.run(run())
def test_no_embedding_configured(tmp_path):
"""No as_embedding anywhere: the chain runs and dedups exactly (BM25-only).
Paraphrase-level candidates survive (no semantic gate), exact normalize
duplicates of historical exposure still drop; nothing crashes or skips.
"""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "notes")
_write_state(ws, open_topics=[_state_topic("已知主题")])
_write_interests_v1(ws, "2026-08-12", [{"title": "历史曝光主题", "reason": "x"}])
reply = _reply(
follow_ups=[
_topic("已知主题的不同说法", paths=[f"daily/{DAY}/session.md"]),
_topic("历史曝光主题", paths=[f"daily/{DAY}/session.md"]),
],
)
_, _, _, context, _ = await _run_chain(ws, [reply]) # no as_embedding in context/app
state = context.get("proactive")
assert state["dropped_known"] == 0
assert state["dropped_duplicate"] == 1
titles = [t["title"] for t in _read_state(ws)["open_topics"]]
assert "已知主题的不同说法" in titles
assert "历史曝光主题" not in titles
asyncio.run(run())
# ---------------------------------------------------------------------------
# F1 contracts
# ---------------------------------------------------------------------------
def test_normalize_and_topic_id_frozen():
"""A7 frozen contract: NFKC+casefold, keep L/N only; id is a 12-hex sha1 prefix."""
assert normalize_topic("Memory Search: 可解释性评估!") == "memorysearch可解释性评估"
assert topic_id("A") == topic_id("a") == topic_id(" A ")
assert topic_id("A") != topic_id("B")
assert len(topic_id("anything")) == 12
def test_legacy_file_parse(tmp_path):
"""v1 files parse without error; new fields take deterministic defaults (A2)."""
content = (
'date: "2026-08-13"\n'
"topic_count: 1\n"
"topics:\n"
" - title: Retrieval quality\n"
" reason: Search behavior changed.\n"
" evidence: daily/2026-08-13/session.md\n"
)
_touch(_interests(tmp_path), content)
topics, is_v1, push = parse_interests_topics(yaml.safe_load(content), DAY)
assert is_v1 is True and push is True
topic = topics[0]
assert topic.id == topic_id("Retrieval quality")
assert topic.kind == "interest_extend"
assert topic.confidence == 0.5
assert topic.first_seen == DAY
assert topic.last_evidence_at == DAY
async def run():
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(tmp_path)))
assert response.success is True
assert response.metadata["push"] is True
asyncio.run(run())
def test_field_fallback():
"""Illegal kind/confidence values fall back deterministically."""
topic = ProactiveTopic(title="T", reason="R", kind="bogus", confidence="not-a-number")
assert topic.kind == "interest_extend"
assert topic.confidence == 0.5
assert ProactiveTopic(confidence=1.7).confidence == 1.0
assert ProactiveTopic(confidence=-3).confidence == 0.0
def test_resolved_registry(tmp_path):
"""Resolved ids disappear from carry-forward and from F5 reads."""
async def run():
ws = tmp_path
id_a, id_b = topic_id("Topic A"), topic_id("Topic B")
_write_state(
ws,
open_topics=[_state_topic("Topic A", topic_id_value=id_a), _state_topic("Topic B", topic_id_value=id_b)],
resolved=[{"id": id_b, "title": "Topic B", "resolved_at": DAY, "evidence": ""}],
)
state_file, needs_bootstrap = load_state(ws)
assert needs_bootstrap is False
carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
assert [t.title for t in carry_all] == ["Topic A"]
_write_interests_v2(
ws,
DAY,
[_state_topic("Topic A", topic_id_value=id_a), _state_topic("Topic B", topic_id_value=id_b)],
)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
assert [t["title"] for t in response.answer["topics"]] == ["Topic A"]
asyncio.run(run())
def test_carry_forward_bootstrap(tmp_path):
"""First run (missing state file) bootstraps from interests.yaml history; first_seen = min."""
async def run():
ws = tmp_path
_write_interests_v1(ws, "2026-08-11", [_topic("Topic T", paths=["daily/2026-08-11/a.md"])])
_write_interests_v1(
ws,
"2026-08-12",
[_topic("Topic T", paths=["daily/2026-08-12/a.md"]), _topic("Topic U", paths=["daily/2026-08-12/b.md"])],
)
# Upgrade path: no _proactive.yaml yet -> one-time bootstrap from history.
state_file, needs_bootstrap = load_state(ws)
assert needs_bootstrap is True
carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20, needs_bootstrap=needs_bootstrap)
by_title = {t.title: t for t in carry_all}
assert set(by_title) == {"Topic T", "Topic U"}
assert by_title["Topic T"].first_seen == "2026-08-11" # min across days, not latest
assert by_title["Topic U"].first_seen == "2026-08-12"
persisted = _read_state(ws)
assert len(persisted["open_topics"]) == 2
_, needs_bootstrap2 = load_state(ws)
assert needs_bootstrap2 is False # no re-bootstrap once the key exists
# Empty open_topics list is a normal state, not a bootstrap trigger.
_write_state(ws, open_topics=[])
_, needs_bootstrap3 = load_state(ws)
assert needs_bootstrap3 is False
# An existing file lacking the key (e.g. hand-edited) still triggers it.
_write_state(ws, budget={}, omit_open_topics=True)
_, needs_bootstrap4 = load_state(ws)
assert needs_bootstrap4 is True
asyncio.run(run())
def test_state_file_corrupt_no_bootstrap(tmp_path):
"""Corrupt _proactive.yaml rebuilds empty WITHOUT bootstrap (spec F1.3/A2/A5)."""
async def run():
ws = tmp_path
_write_interests_v1(ws, "2026-08-12", [_topic("Topic U", paths=["daily/2026-08-12/b.md"])])
_touch(ws / "daily" / "_proactive.yaml", "version: [unclosed")
state_file, needs_bootstrap = load_state(ws)
assert needs_bootstrap is False
assert state_file.open_topics == []
carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20, needs_bootstrap=needs_bootstrap)
assert carry_all == []
asyncio.run(run())
def test_bootstrap_upgrade_chain(tmp_path):
"""Upgrade path: the first refresh run seeds the truth source from interests history."""
async def run():
ws = tmp_path
_write_interests_v1(ws, "2026-08-12", [_topic("历史主题", paths=["daily/2026-08-12/a.md"])])
_touch(ws / "daily" / DAY / "s1.md", "new evidence")
reply = _reply(follow_ups=[_topic("新主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
_, _, _, context, responses = await _run_chain(ws, [reply])
assert all(r.success for r in responses)
assert context.get("proactive")["carry_forward_count"] == 1 # history reached the chain context
truth_titles = {t["title"] for t in _read_state(ws)["open_topics"]}
assert truth_titles == {"历史主题", "新主题"}
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert {t["title"] for t in data["topics"]} == {"历史主题", "新主题"}
asyncio.run(run())
def test_carry_forward_expiry(tmp_path):
"""Topics older than carry_forward_days drop from carry-forward and are pruned."""
async def run():
ws = tmp_path
_write_state(ws, open_topics=[_state_topic("Old Topic", day="2026-07-01")])
state_file, _ = load_state(ws)
carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
assert carry_all == []
_touch(ws / "daily" / DAY / "note.md", "new evidence")
reply = _reply(follow_ups=[], updates=[])
_, _, _, context, responses = await _run_chain(ws, [reply])
assert all(r.success for r in responses)
assert context.get("proactive")["carry_forward_count"] == 0
assert _read_state(ws)["open_topics"] == [] # pruned from the truth source
asyncio.run(run())
def test_material_filter(tmp_path):
"""Day indexes, ``_*`` files and interests.yaml never enter the material set (INV-11)."""
ws = tmp_path
_touch(ws / "daily" / f"{DAY}.md", "day index")
_touch(ws / "daily" / DAY / "_hidden.md", "underscore file")
_touch(ws / "daily" / DAY / "interests.yaml", "topics: []\n")
_touch(ws / "daily" / DAY / "good.md", "material")
assert scan_material_daily(ws, DAY, "daily", 2) == [f"daily/{DAY}/good.md"]
# ---------------------------------------------------------------------------
# F2 discovery + chain behaviour
# ---------------------------------------------------------------------------
def test_refresh_chain_e2e(tmp_path):
"""extract -> topics -> finish runs end to end."""
async def run():
ws = tmp_path
note = _touch(ws / "daily" / DAY / "session.md", "we discussed the eval plan; nothing landed")
reply = _reply(
follow_ups=[_topic("记忆检索的可解释性评估", confidence=0.9, paths=["daily/2026-08-13/session.md"])],
)
app = ApplicationContext(workspace_dir=str(ws))
wrapper = _AgentWrapper([reply])
catalog = _Catalog()
context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
extract = ProactiveExtractStep(app_context=app)
resp_extract = await extract(context)
topics = ProactiveTopicsStep(app_context=app)
resp_topics = await topics(context)
finish = ProactiveFinishStep(app_context=app)
resp_finish = await finish(context)
assert resp_extract.success and resp_topics.success and resp_finish.success
state = context.get("proactive")
assert state["llm_calls"] == 1
assert state["push"] is True
assert state["file_skip_reason"] == ""
assert state["interests_written"] is True
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert data["version"] == 2
assert data["push"] is True
assert data["topics"][0]["title"] == "记忆检索的可解释性评估"
assert data["topics"][0]["kind"] == "follow_up"
truth = _read_state(ws)
assert len(truth["open_topics"]) == 1
checkpoint_paths = {n.path for n in catalog.upserts}
assert checkpoint_paths == {note.relative_to(ws).as_posix()} # interests.yaml not checkpointed (R6)
assert catalog.dumps == 1
asyncio.run(run())
def test_open_loop_follow_up(tmp_path):
"""Branch A emits follow_ups with stable ids; paths restricted to M."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "open loop content")
reply = _reply(
follow_ups=[
_topic("未决事项甲", confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
_topic("越界事项", confidence=0.9, paths=["daily/2026-08-01/absent.md"]),
],
)
_, _, _, context, _ = await _run_chain(ws, [reply])
state = context.get("proactive")
assert len(state["follow_ups"]) == 1
follow_up = state["follow_ups"][0]
assert follow_up["kind"] == "follow_up"
assert follow_up["id"] == topic_id("未决事项甲")
assert state["dropped_missing"] == 1 # out-of-M paths dropped, never repaired
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert [t["title"] for t in data["topics"]] == ["未决事项甲"]
asyncio.run(run())
def test_updates_actions(tmp_path):
"""keep/update/resolve act on the truth source; unknown ids ignored."""
async def run():
ws = tmp_path
id_a, id_b, id_c = topic_id("Topic A"), topic_id("Topic B"), topic_id("Topic C")
_write_state(
ws,
open_topics=[
{**_state_topic("Topic A", day="2026-08-12", topic_id_value=id_a)},
{**_state_topic("Topic B", day="2026-08-12", topic_id_value=id_b)},
{**_state_topic("Topic C", day="2026-08-12", topic_id_value=id_c)},
],
)
_touch(ws / "daily" / DAY / "session.md", "new evidence today")
evidence = f"daily/{DAY}/session.md"
reply = _reply(
follow_ups=[],
updates=[
{"id": id_a, "action": "keep"},
{"id": id_b, "action": "update", "evidence": evidence},
{"id": id_c, "action": "resolve", "evidence": evidence},
{"id": "ffffffffffff", "action": "resolve"},
],
)
_, _, _, context, responses = await _run_chain(ws, [reply])
assert all(r.success for r in responses)
state = context.get("proactive")
assert state["updates_applied"] == 1
assert state["updates_resolved"] == 1
truth = _read_state(ws)
by_id = {t["id"]: t for t in truth["open_topics"]}
assert set(by_id) == {id_a, id_b}
assert by_id[id_a]["last_evidence_at"] == "2026-08-12" # keep: untouched
assert by_id[id_b]["last_evidence_at"] == DAY
assert by_id[id_b]["evidence"] == evidence
resolved_ids = {r["id"] for r in truth["resolved"]}
assert resolved_ids == {id_c}
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert {t["title"] for t in data["topics"]} == {"Topic A", "Topic B"}
asyncio.run(run())
def test_resolved_resurrect(tmp_path):
"""A candidate hitting a tombstone resurrects it: tombstone removed, first_seen kept."""
async def run():
ws = tmp_path
tid = topic_id("Revived Topic")
_write_state(
ws,
resolved=[
{
"id": tid,
"title": "Revived Topic",
"resolved_at": "2026-08-12",
"first_seen": "2026-08-08",
"evidence": "",
},
],
)
_touch(ws / "daily" / DAY / "session.md", "the plan was reopened today")
reply = _reply(follow_ups=[_topic("Revived Topic", confidence=0.7, paths=[f"daily/{DAY}/session.md"])])
_, _, _, context, _ = await _run_chain(ws, [reply])
state = context.get("proactive")
assert [c["title"] for c in state["candidates"]] == ["Revived Topic"]
truth = _read_state(ws)
assert truth["resolved"] == [] # tombstone removed
by_id = {t["id"]: t for t in truth["open_topics"]}
assert tid in by_id
assert by_id[tid]["first_seen"] == "2026-08-08" # original age anchor kept
assert by_id[tid]["last_evidence_at"] == DAY
asyncio.run(run())
def test_trim_freshness_first(tmp_path):
"""max_topics trims by the freshness-first order, regardless of kind (v5 aging)."""
async def run():
ws = tmp_path
stale = [
{**_state_topic(f"Stale Follow {i}", day="2026-08-05", kind="follow_up"), "last_evidence_at": "2026-08-05"}
for i in range(3)
]
fresh = [_state_topic(f"Fresh Extend {i}", day=DAY, kind="interest_extend") for i in range(2)]
_write_state(ws, open_topics=stale + fresh)
_touch(ws / "daily" / DAY / "session.md", "material")
reply = _reply(follow_ups=[_topic("New Follow", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
_, _, _, context, _ = await _run_chain(ws, [reply], topics_kwargs={"max_topics": 4})
titles = [t["title"] for t in context.get("proactive")["topics_out"]]
assert len(titles) == 4
assert titles[0] == "New Follow" # today's follow_up first
assert set(titles[1:3]) == {"Fresh Extend 0", "Fresh Extend 1"} # today's extends next
assert titles[3].startswith("Stale Follow") # only one stale topic survives the cap
asyncio.run(run())
def test_dedup_fallback(tmp_path):
"""M1 fallback: normalize_topic exact match against recent interests drops duplicates."""
async def run():
ws = tmp_path
_write_interests_v1(ws, "2026-08-12", [_topic("Retrieval Evaluation", paths=["daily/2026-08-12/a.md"])])
# Seed an empty truth source so the history topic stays out of open_topics
# (otherwise first-run bootstrap would merge the same-id candidate instead
# of dropping it as a duplicate).
_write_state(ws, open_topics=[])
_touch(ws / "daily" / DAY / "session.md", "material")
reply = _reply(
follow_ups=[_topic("retrieval evaluation!!", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
)
_, _, _, context, _ = await _run_chain(ws, [reply])
state = context.get("proactive")
assert state["dropped_duplicate"] == 1
assert state["candidates"] == []
assert state["file_skip_reason"] == "all_duplicates" # metadata-only since v5 (R7)
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert data["push"] is False
assert data["topics"] == []
asyncio.run(run())
def test_skip_low_confidence(tmp_path):
"""Candidates under min_push_confidence persist but are not pushed."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "weak signal")
reply = _reply(follow_ups=[_topic("弱信号主题", confidence=0.3, paths=[f"daily/{DAY}/session.md"])])
_, _, _, context, _ = await _run_chain(ws, [reply])
state = context.get("proactive")
assert state["push"] is False
assert state["file_skip_reason"] == "low_confidence"
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert data["push"] is False
assert "skip_reason" not in data # metadata-only since v5 (R7)
assert [t["title"] for t in data["topics"]] == ["弱信号主题"] # rendered from truth source
assert [t["title"] for t in _read_state(ws)["open_topics"]] == ["弱信号主题"] # persisted
asyncio.run(run())
def test_no_new_evidence_zero_llm(tmp_path):
"""No changed material -> early exit with zero LLM calls."""
async def run():
ws = tmp_path
note = _touch(ws / "daily" / DAY / "session.md", "already seen")
rel = note.relative_to(ws).as_posix()
catalog = _Catalog([FileNode(path=rel, st_mtime=note.stat().st_mtime)])
wrapper = _AgentWrapper(["should never be consumed"])
_, wrapper, catalog, context, responses = await _run_chain(
ws,
wrapper=wrapper,
catalog=catalog,
)
state = context.get("proactive")
assert state["early_exit"] == "no_new_evidence"
assert wrapper.calls == 0
assert not _interests(ws).exists()
assert catalog.upserts == [] and catalog.dumps == 0
assert all(r.success for r in responses)
asyncio.run(run())
def test_push_sticky(tmp_path):
"""Within one day push only goes up: derived from the cumulative truth source (R1)."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "s1.md", "strong evidence")
reply1 = _reply(follow_ups=[_topic("高置信主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
_, _, _, context1, _ = await _run_chain(ws, [reply1])
assert context1.get("proactive")["push"] is True
assert yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))["push"] is True
_touch(ws / "daily" / DAY / "s2.md", "weak evidence")
reply2 = _reply(follow_ups=[_topic("低置信主题", confidence=0.3, paths=[f"daily/{DAY}/s2.md"])])
_, _, _, context2, _ = await _run_chain(ws, [reply2])
state2 = context2.get("proactive")
assert state2["push"] is True # sticky: the 0.9 topic discovered today persists in truth source
assert state2["file_skip_reason"] == ""
assert yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))["push"] is True
asyncio.run(run())
def test_idempotent_write(tmp_path):
"""Same render skips writing; nightly v1 is adopted via bootstrap (v5 R1)."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "s1.md", "evidence one")
reply1 = _reply(follow_ups=[_topic("主题甲", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
_, _, _, context1, _ = await _run_chain(ws, [reply1])
assert context1.get("proactive")["interests_written"] is True
mtime_before = _interests(ws).stat().st_mtime
_touch(ws / "daily" / DAY / "s2.md", "evidence two, no new topics")
reply2 = _reply(follow_ups=[], updates=[])
time.sleep(0.01)
_, _, _, context2, _ = await _run_chain(ws, [reply2])
assert context2.get("proactive")["interests_written"] is False # identical render
assert _interests(ws).stat().st_mtime == mtime_before
asyncio.run(run())
async def run_v1_nightly_adoption():
"""v5 R1: no v1 guard - first-run bootstrap adopts the nightly topic."""
with tempfile.TemporaryDirectory() as tmp:
ws = Path(tmp)
nightly = [
{
"title": "Nightly topic",
"reason": "written by dream",
"evidence": f"daily/{DAY}/n.md",
"paths": [f"daily/{DAY}/n.md"],
},
]
_write_interests_v1(ws, DAY, nightly)
_touch(ws / "daily" / DAY / "s1.md", "material")
reply = _reply(follow_ups=[], updates=[])
_, _, _, context, _ = await _run_chain(ws, [reply])
state = context.get("proactive")
# Bootstrap gives the nightly topic first_seen = file date (today);
# push is derived true because its fallback confidence 0.5 passes.
assert state["push"] is True
assert state["interests_written"] is True
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert data["version"] == 2
assert [t["title"] for t in data["topics"]] == ["Nightly topic"]
asyncio.run(run_v1_nightly_adoption())
# ---------------------------------------------------------------------------
# F3 isolation / interleave
# ---------------------------------------------------------------------------
def test_refresh_isolation(tmp_path):
"""Refresh never writes digest, never touches the dream catalog or day index."""
async def run():
ws = tmp_path
digest_file = _touch(ws / "digest" / "wiki" / "existing.md", "digest content")
day_index = _touch(ws / "daily" / f"{DAY}.md", "index content")
_touch(ws / "daily" / DAY / "session.md", "conversation")
reply = _reply(follow_ups=[_topic("隔离主题", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
proactive_catalog = _Catalog()
dream_catalog = _Catalog()
_, _, _, _, responses = await _run_chain(ws, [reply], catalog=proactive_catalog)
assert all(r.success for r in responses)
assert digest_file.read_text(encoding="utf-8") == "digest content"
assert day_index.read_text(encoding="utf-8") == "index content"
assert not dream_catalog.upserts and dream_catalog.dumps == 0 and not dream_catalog.deleted
assert proactive_catalog.dumps == 1
assert {n.path for n in proactive_catalog.upserts} == {f"daily/{DAY}/session.md"}
asyncio.run(run())
def test_nightly_overwrite_continuity(tmp_path):
"""Nightly v1 overwrite cannot break continuity: truth source restores exposure."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "s1.md", "round one")
reply1 = _reply(follow_ups=[_topic("持续主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
await _run_chain(ws, [reply1])
truth_before = _read_state(ws)["open_topics"]
# Nightly dream overwrites the exposure product with a v1 file.
_write_interests_v1(ws, DAY, [_topic("Nightly only", paths=[f"daily/{DAY}/s1.md"])])
_touch(ws / "daily" / DAY / "s2.md", "round two")
reply2 = _reply(follow_ups=[], updates=[])
_, _, _, context, _ = await _run_chain(ws, [reply2])
assert context.get("proactive")["interests_written"] is True
assert _read_state(ws)["open_topics"] == truth_before # truth source intact
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert data["version"] == 2 # exposure restored from the truth source
assert [t["title"] for t in data["topics"]] == ["持续主题"]
asyncio.run(run())
def test_refresh_nightly_interleave(tmp_path):
"""Refresh and nightly writes interleave without corrupting state or continuity."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "s1.md", "round one")
reply1 = _reply(follow_ups=[_topic("连续主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
await _run_chain(ws, [reply1])
# Nightly dream overwrites interests.yaml with a v1 file.
_write_interests_v1(ws, DAY, [_topic("Nightly topic", paths=[f"daily/{DAY}/s1.md"])])
nightly = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert nightly.get("version") is None
# Next refresh round re-renders from the intact truth source.
_touch(ws / "daily" / DAY / "s2.md", "round two")
reply2 = _reply(follow_ups=[], updates=[])
_, _, _, _, responses = await _run_chain(ws, [reply2])
assert all(r.success for r in responses)
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert data["version"] == 2
assert data["push"] is True
assert [t["title"] for t in data["topics"]] == ["连续主题"]
assert [t["title"] for t in _read_state(ws)["open_topics"]] == ["连续主题"]
asyncio.run(run())
def test_nightly_unit_set_invariant():
"""Dream extract sees identical units whether or not refresh ran first (INV-9)."""
dream_reply = (
"```yaml\n"
"units:\n"
" - name: eval-plan-gap\n"
" bucket: wiki\n"
" summary: The evaluation plan is discussed but never lands.\n"
f" paths: [daily/{DAY}/session.md]\n"
"topics: []\n"
"```"
)
async def dream_units(ws) -> list[dict]:
catalog = _Catalog()
wrapper = _AgentWrapper([dream_reply])
step = DreamExtractStep(scan_days=1, app_context=ApplicationContext(workspace_dir=str(ws)))
with patch("reme.steps.evolve.dream.extract.refresh_day_index", return_value={}):
response = await step(
RuntimeContext(
date=DAY,
file_catalog=catalog,
file_store=_FileStore(ws),
agent_wrapper=wrapper,
as_llm=_DummyModel(),
),
)
assert response.success is True
return response.metadata["dream"]["units"]
async def run():
with tempfile.TemporaryDirectory() as tmp_a, tempfile.TemporaryDirectory() as tmp_b:
ws_a, ws_b = Path(tmp_a), Path(tmp_b)
for ws in (ws_a, ws_b):
_touch(ws / "daily" / DAY / "session.md", "same discussion content")
# Run the full proactive chain on ws_a only.
reply = _reply(follow_ups=[_topic("主动主题", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
await _run_chain(ws_a, [reply])
units_a = await dream_units(ws_a)
units_b = await dream_units(ws_b)
assert units_a == units_b
assert len(units_a) == 1
asyncio.run(run())
# ---------------------------------------------------------------------------
# F4 short-circuit, budget, timeout
# ---------------------------------------------------------------------------
def test_llm_timeout(tmp_path):
"""A hanging reply times out, short-circuits the round, and retries next round."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "fresh material")
wrapper = _AgentWrapper(["never returned"], delay=0.5)
catalog = _Catalog()
_, wrapper, catalog, context, responses = await _run_chain(
ws,
wrapper=wrapper,
catalog=catalog,
extract_kwargs={"llm_timeout_seconds": 0.1},
)
assert context.get("proactive_skip")["reason"] == "llm_timeout"
assert wrapper.calls == 1
assert not _interests(ws).exists()
assert catalog.upserts == [] and catalog.dumps == 0
assert all(r.success for r in responses)
asyncio.run(run())
def test_parse_failure_retry_then_empty(tmp_path):
"""Unparseable output retries once; still failing yields empty, not error."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
wrapper = _AgentWrapper(["this is not structured at all", _reply(follow_ups=[], updates=[])])
_, wrapper, _, context, responses = await _run_chain(ws, wrapper=wrapper)
assert wrapper.calls == 2
assert all(r.success for r in responses)
assert context.get("proactive")["follow_ups"] == []
asyncio.run(run())
def test_parse_double_failure_keeps_material_uncheckpointed(tmp_path):
"""Two unusable replies skip without checkpoint; material retries next round (audit #1)."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
wrapper = _AgentWrapper(["garbage one", "garbage two"])
catalog = _Catalog()
app = ApplicationContext(workspace_dir=str(ws))
context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
extract = ProactiveExtractStep(app_context=app)
resp_extract = await extract(context)
topics = ProactiveTopicsStep(app_context=app)
resp_topics = await topics(context)
finish = ProactiveFinishStep(app_context=app)
resp_finish = await finish(context)
assert all(r.success for r in (resp_extract, resp_topics, resp_finish))
assert wrapper.calls == 2
assert context.get("proactive_skip") == {"reason": "extract_parse_failed"}
assert not catalog.upserts # nothing checkpointed; material stays changed
asyncio.run(run())
def test_parse_extract_reply_schema_gate():
"""Non-empty replies without contract sections count as parse failures (audit #1)."""
assert parse_extract_reply("followups:\n - title: misspelled section\n") == {}
assert parse_extract_reply("foo: 1") == {}
assert parse_extract_reply("```yaml\nfollow_ups: []\n```") == {"follow_ups": []}
assert parse_extract_reply("follow_ups: []\nupdates: []") == {"follow_ups": [], "updates": []}
def test_schema_error_reply_retries(tmp_path):
"""Non-empty YAML with wrong section names retries once like a parse failure (audit #1)."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
bad = "followups:\n - title: 拼错段名的话题\n reason: because\n"
good = _reply(follow_ups=[_topic("补救话题", paths=[f"daily/{DAY}/session.md"])])
wrapper = _AgentWrapper([bad, good])
_, wrapper, _, context, responses = await _run_chain(ws, wrapper=wrapper)
assert wrapper.calls == 2
assert all(r.success for r in responses)
assert [t["title"] for t in context.get("proactive")["follow_ups"]] == ["补救话题"]
asyncio.run(run())
def test_pack_paths_total_budget(tmp_path):
"""max_total_chars keeps the first file and reports how many were omitted (audit #5)."""
for name in ("a.md", "b.md", "c.md"):
_touch(tmp_path / name, "x" * 100)
packed = pack_paths(tmp_path, ["a.md", "b.md", "c.md"], limit_per_file=60000, max_total_chars=150)
assert "### a.md" in packed
assert "### b.md" not in packed
assert "(omitted 2 file(s)" in packed
def test_extract_material_budget(tmp_path):
"""Blob packing honors max_total_chars; newest daily material is packed first (audit #5)."""
async def run():
ws = tmp_path
_touch(ws / "daily" / "2026-08-12" / "big-old.md", "x" * 500)
_touch(ws / "daily" / DAY / "fresh.md", "fresh material")
reply = _reply(follow_ups=[_topic("预算话题", paths=[f"daily/{DAY}/fresh.md"])])
wrapper = _AgentWrapper([reply])
await _run_chain(ws, wrapper=wrapper, extract_kwargs={"max_total_chars": 200})
blob = wrapper.last_inputs[0]
assert "### daily/2026-08-13/fresh.md" in blob # newest day survives the budget
assert "### daily/2026-08-12/big-old.md" not in blob # omitted from the material section
assert "omitted" in blob
asyncio.run(run())
# ---------------------------------------------------------------------------
# F5 read interface
# ---------------------------------------------------------------------------
def test_proactive_backward_compat(tmp_path):
"""Default params on a v1 file keep the legacy answer field-for-field."""
async def run():
content = (
"date: 2026-05-28\n"
"topics:\n"
" - title: Retrieval quality\n"
" reason: Search behavior changed repeatedly.\n"
" evidence: daily/2026-05-28/session.md\n"
)
_touch(tmp_path / "daily" / "2026-05-28" / "interests.yaml", content)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(
RuntimeContext(date="2026-05-28", include_content=True, file_store=_FileStore(tmp_path)),
)
assert response.success is True
assert response.answer == {
"summary": "Read 1 proactive topic(s) from daily/2026-05-28/interests.yaml",
"topics": [
{
"title": "Retrieval quality",
"reason": "Search behavior changed repeatedly.",
"evidence": "daily/2026-05-28/session.md",
"paths": [],
},
],
"content": content,
}
assert response.metadata["topics"] == response.answer["topics"]
assert response.metadata["content"] == content
assert response.metadata["push"] is True # additive keys only
asyncio.run(run())
def test_proactive_answer_omits_unrequested_content(tmp_path):
"""Raw YAML is absent from the primary answer when include_content is false."""
async def run():
_touch(tmp_path / "daily" / DAY / "interests.yaml", "topics:\n - title: Topic\n reason: Reason\n")
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(tmp_path)))
assert response.success is True
assert "content" not in response.answer
assert response.answer["topics"][0]["title"] == "Topic"
assert response.metadata["content"] == ""
asyncio.run(run())
def test_proactive_missing_file_is_an_explicit_skip(tmp_path):
"""A missing single-day exposure remains distinguishable without metadata."""
async def run():
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(RuntimeContext(date=DAY, file_store=_FileStore(tmp_path)))
assert response.success is True
assert response.answer == f"Skipped: interests file not found at daily/{DAY}/interests.yaml"
assert response.metadata["skipped"] is True
asyncio.run(run())
def test_strict_topic_loading_rejects_lossy_fields(tmp_path):
"""Strict legacy loading rejects values that would otherwise be discarded."""
target = _touch(tmp_path / "interests.yaml", "topics:\n - title: Topic\n reason: Reason\n custom: keep me\n")
try:
load_yaml_topics(target, strict=True)
except ValueError as exc:
assert "unknown field(s): custom" in str(exc)
else:
raise AssertionError("strict topic loading accepted a lossy field")
def test_strict_topic_loading_rejects_invalid_field_types(tmp_path):
"""Strict legacy loading rejects list fields that would otherwise be normalized away."""
target = _touch(
tmp_path / "interests.yaml",
"topics:\n - title: Topic\n reason: Reason\n paths: daily/source.md\n",
)
try:
load_yaml_topics(target, strict=True)
except ValueError as exc:
assert "topics[0].paths must be a list of non-empty strings" in str(exc)
else:
raise AssertionError("strict topic loading accepted an invalid paths type")
def test_horizon_merge(tmp_path):
"""Horizon>1 reads the truth source filtered by evidence recency (v5 R4)."""
async def run():
ws = tmp_path
id_x, id_y = topic_id("Topic X"), topic_id("Topic Y")
_write_state(
ws,
open_topics=[
_state_topic("Topic X", day=DAY, topic_id_value=id_x, confidence=0.9),
_state_topic("Topic Y", day="2026-08-09", topic_id_value=id_y, kind="interest_extend", confidence=0.6),
],
)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)), horizon_days=3)
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
assert response.success is True
assert [t["title"] for t in response.answer["topics"]] == ["Topic X"] # Y evidence too old
assert response.metadata["path"] == "daily/_proactive.yaml"
# A wider horizon includes the older topic again.
response2 = await step(
RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws), horizon_days=7),
)
assert {t["title"] for t in response2.answer["topics"]} == {"Topic X", "Topic Y"}
# Historical reads are bounded on both sides and cannot expose topics
# whose evidence is newer than the requested date.
historical = await step(
RuntimeContext(date="2026-08-10", include_content=False, file_store=_FileStore(ws), horizon_days=7),
)
assert [t["title"] for t in historical.answer["topics"]] == ["Topic Y"]
# Empty truth source -> skipped, not an error.
with tempfile.TemporaryDirectory() as tmp:
empty_ws = Path(tmp)
response3 = await step(
RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(empty_ws), horizon_days=3),
)
assert response3.success is True
assert response3.metadata["skipped"] is True
asyncio.run(run())
def test_min_confidence(tmp_path):
"""min_confidence filters v2 topics; v1 topics fall back to 0.5."""
async def run():
ws = tmp_path
_write_interests_v2(
ws,
DAY,
[
_state_topic("High topic", confidence=0.7),
_state_topic("Low topic", kind="interest_extend", confidence=0.3),
],
)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)), min_confidence=0.5)
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
assert [t["title"] for t in response.answer["topics"]] == ["High topic"]
# Default min_confidence=0.4 sits below the 0.5 fallback but above weak 0.3.
step_default = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
default_response = await step_default(
RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)),
)
assert [t["title"] for t in default_response.answer["topics"]] == ["High topic"]
with tempfile.TemporaryDirectory() as tmp:
ws_v1 = Path(tmp)
_write_interests_v1(ws_v1, DAY, [_topic("Legacy topic", paths=[f"daily/{DAY}/a.md"])])
step_v1 = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws_v1)))
ok = await step_v1(
RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws_v1), min_confidence=0.5),
)
assert [t["title"] for t in ok.answer["topics"]] == ["Legacy topic"] # fallback 0.5 passes
filtered = await step_v1(
RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws_v1), min_confidence=0.6),
)
assert filtered.answer["topics"] == []
asyncio.run(run())
# ---------------------------------------------------------------------------
# M2: extends branch + semantic dedup
# ---------------------------------------------------------------------------
def test_extend_from_daily_material(tmp_path):
"""Branch B infers extends from daily material; out-of-M paths are dropped."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "discussion about source attribution")
reply = _reply(
follow_ups=[],
extends=[
_topic("引用质量评测", confidence=0.5, paths=[f"daily/{DAY}/session.md"]),
_topic("越界扩展", confidence=0.5, paths=["daily/2026-08-01/absent.md"]),
],
)
_, wrapper, _, context, _ = await _run_chain(ws, [reply])
assert "extends" in wrapper.last_inputs[0] # extends section rendered
state = context.get("proactive")
assert len(state["extends"]) == 1
extend = state["extends"][0]
assert extend["kind"] == "interest_extend"
assert f"daily/{DAY}/session.md" in extend["paths"]
assert state["dropped_missing"] == 1
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert [t["title"] for t in data["topics"]] == ["引用质量评测"]
asyncio.run(run())
def test_extends_disabled_m1_shape():
"""extends_enabled=False restores the M1 prompt shape and ignores extends output."""
async def run_once(extends_enabled: bool):
with tempfile.TemporaryDirectory() as tmp:
ws = Path(tmp)
_touch(ws / "daily" / DAY / "s1.md", "material")
reply = _reply(
follow_ups=[_topic("Follow Up Topic", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])],
extends=[_topic("Extends Topic", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])],
)
_, wrapper, _, context, _ = await _run_chain(
ws,
[reply],
extract_kwargs={"extends_enabled": extends_enabled},
)
return wrapper, context.get("proactive")
async def run():
wrapper_on, state_on = await run_once(True)
assert "extends:" in wrapper_on.last_system_prompts[0] # M2 default renders branch B
assert [t["title"] for t in state_on["extends"]] == ["Extends Topic"]
wrapper_off, state_off = await run_once(False)
system_off = wrapper_off.last_system_prompts[0]
assert "extends" not in system_off.replace("interest_extend", "") # M1 prompt shape
assert state_off["extends"] == [] # extends entries in the reply are ignored
assert [t["title"] for t in state_off["topics_out"]] == ["Follow Up Topic"]
asyncio.run(run())
def test_semantic_dedup(tmp_path):
"""With embeddings, similarity below known_threshold is kept; no LLM tier (v5 R2)."""
async def run():
ws = tmp_path
_write_state(ws, open_topics=[_state_topic("Gray Comparison", kind="interest_extend")])
_touch(ws / "daily" / DAY / "session.md", "material")
reply = _reply(
follow_ups=[
_topic("Candidate Topic", confidence=0.8, paths=[f"daily/{DAY}/session.md"]),
],
)
fake = _FakeEmbedding(
{
# v5.2 scheme: candidate and known sides both embed "title + CJK period + reason"
"Candidate Topic。because": [1.0, 0.0],
"Gray Comparison。seeded": [0.8, 0.6], # cos = 0.800 < 0.85 -> kept, no second tier
},
)
_, wrapper, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": fake})
state = context.get("proactive")
assert state["dropped_known"] == 0
assert state["dropped_duplicate"] == 0
assert [c["title"] for c in state["candidates"]] == ["Candidate Topic"]
assert fake.calls >= 1
assert wrapper.calls == 1 # topics step is pure computation now
asyncio.run(run())
def test_known_drop(tmp_path):
"""sim >= known_threshold drops the candidate as already known."""
async def run():
ws = tmp_path
_write_state(ws, open_topics=[_state_topic("Known Topic", kind="interest_extend")])
_touch(ws / "daily" / DAY / "session.md", "material")
reply = _reply(
follow_ups=[_topic("Fresh Candidate", confidence=0.8, paths=[f"daily/{DAY}/session.md"])],
)
fake = _FakeEmbedding(
{
"Fresh Candidate。because": [1.0, 0.0],
"Known Topic。seeded": [1.0, 0.0], # identical -> sim 1.0 >= 0.85
},
)
_, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": fake})
state = context.get("proactive")
assert state["dropped_known"] == 1
assert state["candidates"] == []
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert [t["title"] for t in data["topics"]] == ["Known Topic"]
asyncio.run(run())
# ---------------------------------------------------------------------------
# F2.5/F2.6 plan + agenda steps
# ---------------------------------------------------------------------------
def _plan_reply(cards: list[dict]) -> str:
body = yaml.safe_dump({"cards": cards}, allow_unicode=True, sort_keys=False)
return f"```yaml\n{body}```"
def _agenda_reply(agenda: list[dict], suppressed: list[dict] | None = None) -> str:
doc = {"agenda": agenda}
if suppressed is not None:
doc["suppressed"] = suppressed
body = yaml.safe_dump(doc, allow_unicode=True, sort_keys=False)
return f"```yaml\n{body}```"
def _card(topic_id_value, index=0, scenario="resume_task"):
return {
"topic_id": topic_id_value,
"scenario_type": scenario,
"opener": f"对了,上次那件事还差一步({index}),要不要现在动起来?",
"next_action": f"执行第 {index}",
"preconditions": [],
"delivery": "in_conversation",
}
async def _run_full_chain(
ws,
replies=None,
*,
wrapper=None,
catalog=None,
app=None,
extract_kwargs=None,
topics_kwargs=None,
plan_kwargs=None,
agenda_kwargs=None,
extra_ctx=None,
context=None,
):
app = app or ApplicationContext(workspace_dir=str(ws))
wrapper = wrapper or _AgentWrapper(replies or [])
catalog = catalog or _Catalog()
context = context or RuntimeContext(
date=DAY,
file_catalog=catalog,
file_store=_FileStore(ws),
agent_wrapper=wrapper,
**(extra_ctx or {}),
)
extract = ProactiveExtractStep(app_context=app, **(extract_kwargs or {}))
resp_extract = await extract(context)
topics = ProactiveTopicsStep(app_context=app, **(topics_kwargs or {}))
resp_topics = await topics(context)
plan = ProactivePlanStep(app_context=app, **(plan_kwargs or {}))
resp_plan = await plan(context)
agenda = ProactiveAgendaStep(app_context=app, **(agenda_kwargs or {}))
resp_agenda = await agenda(context)
finish = ProactiveFinishStep(app_context=app)
resp_finish = await finish(context)
return app, wrapper, catalog, context, (resp_extract, resp_topics, resp_plan, resp_agenda, resp_finish)
def test_plan_agenda_chain_e2e(tmp_path):
"""extract -> topics -> plan -> agenda -> finish writes the enriched v2 file."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "we discussed the eval plan")
extract_reply = _reply(
follow_ups=[_topic("记忆检索的可解释性评估", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
extends=[_topic("多跳 RAG 综述", confidence=0.5, paths=[f"daily/{DAY}/session.md"])],
)
fid, eid = topic_id("记忆检索的可解释性评估"), topic_id("多跳 RAG 综述")
plan_reply = _plan_reply(
[
{
"topic_id": fid,
"scenario_type": "resume_task",
"opener": "对了,上次那个评估还差一步,要不要现在跑起来?",
"next_action": "运行评估脚本",
"preconditions": ["数据集已就绪"],
"delivery": "agenda_item",
},
_card(eid, 1, scenario="explore_interest"),
],
)
agenda_text = _agenda_reply(
[{"topic_id": fid, "order_reason": "卡点已解除,今天最适合收尾"}],
[{"topic_id": eid, "reason": f"merged into {fid}"}],
)
_, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
assert all(r.success for r in resps)
assert wrapper.calls == 3
state = context.get("proactive")
assert state["llm_calls"] == 1 # extract only
assert state["plan_llm_calls"] == 2 # plan + agenda
assert state["push"] is True
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert data["version"] == 2
assert data["push"] is True
assert len(data["agenda"]) == 1
item = data["agenda"][0]
assert item["topic_id"] == fid
assert item["title"] == "记忆检索的可解释性评估"
assert item["scenario_type"] == "resume_task"
assert item["opener"].startswith("对了")
assert item["next_action"] == "运行评估脚本"
assert item["preconditions"] == ["数据集已就绪"]
assert item["delivery"] == "agenda_item"
assert item["linked_memory"] == [f"daily/{DAY}/session.md"]
assert item["order_reason"] == "卡点已解除,今天最适合收尾"
assert data["suppressed"] == [
{"topic_id": eid, "title": "多跳 RAG 综述", "reason": f"merged into {fid}"},
]
assert {t["id"] for t in data["topics"]} == {fid, eid} # topics view intact
asyncio.run(run())
def test_plan_agenda_fallback_without_llm(tmp_path):
"""Unusable plan/agenda replies degrade to fallback cards + deterministic agenda."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
extract_reply = _reply(
follow_ups=[
_topic("任务一", confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
_topic("任务二", confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
],
)
# Only the extract reply is scripted; plan/agenda receive "" and must degrade.
_, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply])
assert all(r.success for r in resps)
assert wrapper.calls == 3
state = context.get("proactive")
assert state["plan_llm_calls"] == 2 # attempted but unusable
assert len(state["scenario_cards"]) == 2
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert data["push"] is True
assert [i["title"] for i in data["agenda"]] == ["任务一", "任务二"]
assert all(i["order_reason"] == "deterministic fallback: freshness/confidence order" for i in data["agenda"])
assert data["suppressed"] == []
assert all(i["scenario_type"] == "resume_task" for i in data["agenda"])
assert all(i["opener"] for i in data["agenda"])
asyncio.run(run())
def test_agenda_no_candidates_keeps_topics_render(tmp_path):
"""Without push candidates plan/agenda skip and the topics-only render stays."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
extract_reply = _reply(
follow_ups=[_topic("低置信话题", confidence=0.3, paths=[f"daily/{DAY}/session.md"])],
)
_, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply])
assert all(r.success for r in resps)
assert wrapper.calls == 1 # extract only; plan/agenda made zero LLM calls
state = context.get("proactive")
assert state["push"] is False
assert state["push_candidates"] == []
assert state["scenario_cards"] == []
assert state["agenda"] == []
assert state["suppressed"] == []
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert data["push"] is False
assert "agenda" not in data and "suppressed" not in data
asyncio.run(run())
def test_agenda_single_candidate_skips_llm(tmp_path):
"""Exactly one candidate is auto-agenda without an agenda LLM call."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
extract_reply = _reply(
follow_ups=[_topic("唯一候选", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
)
fid = topic_id("唯一候选")
plan_reply = _plan_reply([_card(fid, scenario="answer_pending")])
_, wrapper, _, context, _ = await _run_full_chain(ws, [extract_reply, plan_reply])
assert wrapper.calls == 2 # extract + plan only
state = context.get("proactive")
assert state["plan_llm_calls"] == 1
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert len(data["agenda"]) == 1
assert data["agenda"][0]["topic_id"] == fid
assert data["agenda"][0]["order_reason"] == "single push candidate; auto-agenda without LLM"
assert data["suppressed"] == []
asyncio.run(run())
def test_agenda_merge_and_full_accounting(tmp_path):
"""LLM merges two entrances of one matter; every candidate is accounted for."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
titles = ["GPU 排期确认", "跑 benchmark 的 GPU 档期", "多跳引用可信度"]
extract_reply = _reply(
follow_ups=[
_topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
_topic(titles[1], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
_topic(titles[2], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
],
)
ids = [topic_id(t) for t in titles]
plan_reply = _plan_reply([_card(tid, i) for i, tid in enumerate(ids)])
agenda_text = _agenda_reply(
[{"topic_id": ids[0], "order_reason": "主入口"}, {"topic_id": ids[2], "order_reason": "独立事项"}],
[{"topic_id": ids[1], "reason": f"merged into {ids[0]}"}],
)
_, _, _, context, _ = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
state = context.get("proactive")
assert state["push"] is True
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert [i["topic_id"] for i in data["agenda"]] == [ids[0], ids[2]]
assert data["suppressed"] == [
{"topic_id": ids[1], "title": titles[1], "reason": f"merged into {ids[0]}"},
]
accounted = {i["topic_id"] for i in data["agenda"]} | {x["topic_id"] for x in data["suppressed"]}
assert accounted == set(ids)
asyncio.run(run())
def test_agenda_invalid_ids_and_missing_accounting(tmp_path):
"""Bogus agenda ids are dropped; candidates the LLM forgot are still suppressed."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
titles = ["候选甲", "候选乙"]
extract_reply = _reply(
follow_ups=[
_topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
_topic(titles[1], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
],
)
ids = [topic_id(t) for t in titles]
plan_reply = _plan_reply([_card(tid, i) for i, tid in enumerate(ids)])
agenda_text = _agenda_reply(
[{"topic_id": "nonexistent01", "order_reason": "bogus"}, {"topic_id": ids[0], "order_reason": "real"}],
[],
)
_, _, _, _, _ = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert [i["topic_id"] for i in data["agenda"]] == [ids[0]]
assert data["suppressed"] == [
{"topic_id": ids[1], "title": titles[1], "reason": "not selected for today's agenda"},
]
asyncio.run(run())
def test_plan_over_budget_suppressed(tmp_path):
"""Candidates beyond max_plan_topics stay card-less and are suppressed with reason."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
titles = ["高置信一", "高置信二", "高置信三"]
extract_reply = _reply(
follow_ups=[
_topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
_topic(titles[1], confidence=0.8, paths=[f"daily/{DAY}/session.md"]),
_topic(titles[2], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
],
)
ids = [topic_id(t) for t in titles]
# deterministic sort order: confidence desc -> ids[0], ids[1] selected; ids[2] over budget
plan_reply = _plan_reply([_card(ids[0], 0), _card(ids[1], 1)])
agenda_text = _agenda_reply(
[{"topic_id": ids[0], "order_reason": "first"}, {"topic_id": ids[1], "order_reason": "second"}],
[],
)
_, _, _, context, _ = await _run_full_chain(
ws,
[extract_reply, plan_reply, agenda_text],
plan_kwargs={"max_plan_topics": 2},
)
state = context.get("proactive")
assert len(state["scenario_cards"]) == 2
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert [i["topic_id"] for i in data["agenda"]] == [ids[0], ids[1]]
assert data["suppressed"] == [
{
"topic_id": ids[2],
"title": titles[2],
"reason": "over plan budget: not expanded into a scenario card",
},
]
asyncio.run(run())
def test_read_side_agenda_passthrough(tmp_path):
"""F5 read passes the agenda through, filtering resolved/unknown/low-confidence ids."""
async def run():
ws = tmp_path
id_a, id_b = topic_id("议程主题"), topic_id("已解决主题")
payload = {
"version": 2,
"date": DAY,
"generated_at": f"{DAY}T09:00:00",
"push": True,
"topics": [
_state_topic("议程主题", topic_id_value=id_a, confidence=0.9),
_state_topic("已解决主题", topic_id_value=id_b, confidence=0.9),
],
"agenda": [
{"topic_id": id_a, "opener": "对了,那件事可以动了"},
{"topic_id": id_b, "opener": "resolved, must be filtered"},
{"topic_id": "unknown000000", "opener": "not in kept"},
],
"suppressed": [],
}
_touch(_interests(ws, DAY), yaml.safe_dump(payload, allow_unicode=True, sort_keys=False))
_write_state(ws, resolved=[{"id": id_b, "title": "已解决主题", "resolved_at": DAY, "evidence": ""}])
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
assert [t["title"] for t in response.answer["topics"]] == ["议程主题"]
assert response.answer["agenda"] == [{"topic_id": id_a, "opener": "对了,那件事可以动了"}]
asyncio.run(run())
# ---------------------------------------------------------------------------
# F2.7 personal profile (digest/personal) + configured daily_dir
# ---------------------------------------------------------------------------
def test_strip_frontmatter_and_profile_loader(tmp_path):
"""digest/personal/*.md -> profile block; per-file budget; fallbacks."""
ws = tmp_path
personal = ws / "digest" / "personal"
personal.mkdir(parents=True)
(personal / "user-profile.md").write_text(
"---\ndescription: 用户张三的身份画像\nname: user-profile\n---\n\n用户张三,偏好西瓜。\n",
encoding="utf-8",
)
(personal / "user-position.md").write_text(
"---\ndescription: 持仓约束\nname: user-position\n---\n\n持有小米,浮亏较深。\n",
encoding="utf-8",
)
meta, body = strip_frontmatter((personal / "user-profile.md").read_text(encoding="utf-8"))
assert meta["name"] == "user-profile"
assert body == "用户张三,偏好西瓜。"
block = load_personal_profile_block(ws, "digest", 4000)
assert "### user-profile" in block and "### user-position" in block
assert "用户张三的身份画像" in block and "偏好西瓜" in block
assert "持仓约束" in block
tiny = load_personal_profile_block(ws, "digest", 300)
assert len(tiny) <= 300
# absent personal dir -> legacy single-file fallback -> sentinel
empty_ws = tmp_path / "empty"
empty_ws.mkdir()
assert load_personal_profile_block(empty_ws, "digest", 1000) == "(no user profile)"
(empty_ws / "profile.md").write_text("legacy profile", encoding="utf-8")
assert load_personal_profile_block(empty_ws, "digest", 1000, "profile.md") == "legacy profile"
assert load_personal_profile_block(empty_ws, "digest", 0, "profile.md") == "(no user profile)"
def test_chain_with_configured_daily_dir(tmp_path):
"""The full chain honours daily_dir=memory (paths, truth source, prompts)."""
async def run():
ws = tmp_path
_touch(ws / "memory" / DAY / "session.md", "小米财报讨论")
_touch(
ws / "digest" / "personal" / "user-profile.md",
"---\ndescription: 张三画像\nname: user-profile\n---\n\n张三关注小米与中概股。\n",
)
extract_reply = _reply(
follow_ups=[_topic("小米Q2财报跟进", confidence=0.9, paths=[f"memory/{DAY}/session.md"])],
)
fid = topic_id("小米Q2财报跟进")
plan_reply = _plan_reply([_card(fid)])
agenda_text = _agenda_reply([{"topic_id": fid, "order_reason": "only candidate"}], [])
app = ApplicationContext(workspace_dir=str(ws), daily_dir="memory", digest_dir="digest")
wrapper = _AgentWrapper([extract_reply, plan_reply, agenda_text])
catalog = _Catalog()
context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
extract = ProactiveExtractStep(app_context=app)
resp_extract = await extract(context)
topics = ProactiveTopicsStep(app_context=app)
resp_topics = await topics(context)
plan = ProactivePlanStep(app_context=app)
resp_plan = await plan(context)
agenda = ProactiveAgendaStep(app_context=app)
resp_agenda = await agenda(context)
finish = ProactiveFinishStep(app_context=app)
resp_finish = await finish(context)
assert all(r.success for r in (resp_extract, resp_topics, resp_plan, resp_agenda, resp_finish))
state = context.get("proactive")
assert state["daily_dir"] == "memory"
assert state["changed_paths"] == [f"memory/{DAY}/session.md"]
# truth source + exposure land under memory/, not daily/
assert (ws / "memory" / "_proactive.yaml").is_file()
data = yaml.safe_load((ws / "memory" / DAY / "interests.yaml").read_text(encoding="utf-8"))
assert data["push"] is True
assert [t["id"] for t in data["topics"]] == [fid]
assert not (ws / "daily").exists()
# profile + configured daily dir reached the prompts
assert any("张三画像" in inp or "张三关注小米" in inp for inp in wrapper.last_inputs)
assert any(f"memory/{DAY}" in inp for inp in wrapper.last_inputs)
assert any("memory/<date>" in sp for sp in wrapper.last_system_prompts)
asyncio.run(run())
def test_finish_defers_paths_modified_during_round(tmp_path):
"""Files edited while the LLM calls run are left for the next round (audit 3)."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "a.md", "first note")
note_b = _touch(ws / "daily" / DAY / "b.md", "second note")
reply = _reply(follow_ups=[], extends=[])
app = ApplicationContext(workspace_dir=str(ws))
wrapper = _AgentWrapper([reply])
catalog = _Catalog()
context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
resp_extract = await ProactiveExtractStep(app_context=app)(context)
assert resp_extract.success is True
state = context.get("proactive")
assert set(state["changed_paths"]) == {f"daily/{DAY}/a.md", f"daily/{DAY}/b.md"}
assert state["changed_mtimes"][f"daily/{DAY}/b.md"] == note_b.stat().st_mtime
# The user edits b.md while plan/agenda LLM calls are still running.
note_b.write_text("second note, edited mid-round", encoding="utf-8")
future = time.time() + 5
os.utime(note_b, (future, future))
resp_finish = await ProactiveFinishStep(app_context=app)(context)
assert resp_finish.success is True
final_state = context.get("proactive")
assert final_state["checkpoint_paths"] == [f"daily/{DAY}/a.md"]
assert "deferred 1" in resp_finish.answer
assert {n.path for n in catalog.upserts} == {f"daily/{DAY}/a.md"}
asyncio.run(run())
def test_read_single_corrupt_yaml_is_read_only_failure(tmp_path):
"""Corrupt interests.yaml: the read fails loudly and never moves the file (audit 2)."""
async def run():
ws = tmp_path
corrupt = "topics: [unclosed"
path = _touch(ws / "daily" / DAY / "interests.yaml", corrupt)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
response = await step(RuntimeContext(date=DAY, file_store=_FileStore(ws)))
assert response.success is False
assert response.metadata.get("error")
assert path.is_file()
assert path.read_text(encoding="utf-8") == corrupt
assert not list(path.parent.glob("interests.corrupt-*"))
asyncio.run(run())
def test_carry_forward_expiry_boundary_consistent(tmp_path):
"""Age == carry_forward_days is over-age in both prompt loading and trim (audit 9)."""
async def run():
ws = tmp_path
edge_day = (dt.date.fromisoformat(DAY) - dt.timedelta(days=14)).isoformat()
_write_state(ws, open_topics=[_state_topic("Edge Topic", day=edge_day)])
state_file, _ = load_state(ws)
carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
assert carry_all == []
trim_state_file(state_file, DAY, 14)
assert state_file.open_topics == []
asyncio.run(run())