Files
alvinfeiandClaude Opus 5.5 4746681efe fix(sdk): coding reconcile model failures fail the write, nothing half-applied
Experience reconcile no longer falls back to ADD-all, and fact reconcile no
longer inserts without superseding, when the model call fails, returns empty
content, or returns a reply that is not a JSON object with an ops list. The
write returns success=false, agent_error_code=RECONCILE_FAILED and the
cause in reconcile_error_code (LLM_UNAVAILABLE / EMPTY_RESPONSE /
JSON_PARSE_FAILED), the same as chat.

Each reconciler is split into plan() (retrieve + model decision, no store)
and apply() (store). CodingWriter plans both layers before applying either,
so a decision failure in either layer stores no experience or fact and
supersedes no older fact. reconcile() still runs plan + apply.

An experience reply of {"ops": []} is a valid "no changes" decision and
stores nothing. A non-empty ops list with no recognizable op is a parse
failure. A fact reply of {"ops": []} inserts without superseding.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 17:01:47 +08:00

650 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
端到端写入路径测试。
把 judge / preproc / extractor / reconciler / store 串起来,
验证 CodingWriter 协调正确。
"""
import json
import pytest
from hy_memory.coding.extractor import CodingMemoryExtractor
from hy_memory.coding.reconciler import CodingMemoryReconciler
from hy_memory.coding.writer import CodingWriter, is_coding_segment
from hy_memory.pipelines.base import ChatMessage, ToolCall
from tests.coding._helpers import MockLLMProvider, FailingLLMProvider
def _classify_resp(is_coding):
return json.dumps({"is_coding": is_coding})
def _extract_resp(experiences, fact=None):
if isinstance(fact, list):
if len(fact) <= 1:
fact = fact[0] if fact else None
else:
# 仅供兼容性回归:旧模型可能仍返回多元素 facts 数组。
return json.dumps({"experiences": experiences, "facts": fact})
return json.dumps({"experiences": experiences, "fact": fact})
def _reconcile_resp(ops):
return json.dumps({"ops": ops})
class _FailingEmbed:
"""向量化服务整体不可用。"""
async def embed(self, text):
raise RuntimeError("embedding service down")
async def embed_batch(self, texts):
raise RuntimeError("embedding service down")
def _coding_messages():
return [
ChatMessage(role="user", content="帮我打包发布到私有包索引"),
ChatMessage(role="assistant", content="", tool_calls=[
ToolCall(id="1", name="Read", arguments={"path": "scripts/publish_package.sh"}),
]),
ChatMessage(role="tool", content="200 lines", tool_call_id="1", tool_name="Read"),
ChatMessage(role="assistant", content="", tool_calls=[
ToolCall(id="2", name="Bash", arguments={"command": "twine upload"}),
]),
ChatMessage(role="tool", content="OK uploaded", tool_call_id="2", tool_name="Bash"),
]
@pytest.mark.asyncio
class TestIsCodingSegment:
async def test_no_tool_short_circuit(self):
msgs = [ChatMessage(role="user", content="hi"),
ChatMessage(role="assistant", content="hello")]
# 即使 LLM 返回 true,has_any_tool_message False 应短路 False
llm = MockLLMProvider('{"is_coding": true}')
result = await is_coding_segment(msgs, llm)
assert result is False
# LLM 不应被调用
assert len(llm.calls) == 0
async def test_with_tool_classify_coding(self):
llm = MockLLMProvider(_classify_resp(True))
result = await is_coding_segment(_coding_messages(), llm)
assert result is True
async def test_with_tool_classify_chat(self):
llm = MockLLMProvider(_classify_resp(False))
result = await is_coding_segment(_coding_messages(), llm)
assert result is False
@pytest.mark.asyncio
class TestCodingWriterE2E:
async def test_trace_history_shared_across_working_and_coding_variants(
self, coding_config, mock_embed,
):
from hy_memory.coding.store import CodingMemoryStore
working = CodingMemoryStore(
coding_config, embed_service=mock_embed, variant="working",
)
coding = CodingMemoryStore(
coding_config, embed_service=mock_embed, variant="coding",
)
await working.initialize()
await coding.initialize()
try:
await working.append_trace(
[ChatMessage(role="user", content="先整理财务报表")],
request_id="shared-trace", user_id="u1", agent_id="a1",
)
recent = await coding.get_recent_trace_messages(
user_id="u1", agent_id="a1", max_user_turns=5,
)
assert [m.content for m in recent if m.role == "user"] == [
"先整理财务报表"
]
assert working._db_path != coding._db_path
assert working._trace_db_path == coding._trace_db_path
finally:
await coding.close()
await working.close()
async def test_trace_history_keeps_last_five_user_turns(self, coding_store):
msgs = []
for i in range(7):
msgs.extend([
ChatMessage(role="user", content=f"history-{i}"),
ChatMessage(role="assistant", content="A" * 8000),
ChatMessage(role="tool", content="T" * 5000, tool_name="Read"),
])
await coding_store.append_trace(
msgs, request_id="trace-1", user_id="u1", agent_id="a1",
)
recent = await coding_store.get_recent_trace_messages(
user_id="u1", agent_id="a1", max_user_turns=5,
)
assert [m.content for m in recent if m.role == "user"] == [
"history-2", "history-3", "history-4", "history-5", "history-6"
]
assert all("chars omitted" in m.content for m in recent if m.role == "assistant")
assert all("bytes omitted" in m.content for m in recent if m.role == "tool")
async def test_trace_history_retention_is_bounded(self, coding_store):
for i in range(105):
await coding_store.append_trace(
[ChatMessage(role="user", content=f"turn-{i}")],
request_id=f"trace-{i}", user_id="u1", agent_id="a1",
)
with coding_store._trace_lock:
count = coding_store._trace_conn.execute(
"SELECT COUNT(*) AS n FROM coding_trace WHERE user_id=? AND agent_id=?",
("u1", "a1"),
).fetchone()["n"]
assert count == 100
async def test_full_path_add(self, coding_store, mock_embed):
"""完整 e2e:含 tool 消息 → judge=True → extract → reconcile=ADD → 持久化"""
# 用同一个 LLM mock 序列响应 extract + reconcile(注意:我们这里直接调
# writer.write,不经 judge;所以 LLM 序列只需覆盖 extract + reconcile)
ext_resp = _extract_resp([{
"task": "打包发布到私有包索引",
"search_keys": ["凭证位置", "上传命令"],
"solution": "运行 scripts/publish_package.sh X.Y.Z;凭证通过环境变量提供",
"boundary_envs": "twine>=6\\nurl: packages.example.com",
"confidence": 0.9,
}])
rec_resp = _reconcile_resp([{"draft_idx": 0, "action": "ADD"}])
llm = MockLLMProvider([ext_resp, rec_resp])
ext = CodingMemoryExtractor(llm)
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
writer = CodingWriter(
store=coding_store, extractor=ext, reconciler=rec,
llm_provider=llm, embed_service=mock_embed,
)
resp = await writer.write(
_coding_messages(),
user_id="u1", agent_id="a1",
workspace_id="github.com/x/y", branch="main",
session_id="s1",
)
assert resp["success"] is True
assert resp["scene"] == "productivity"
assert len(resp["ops"]) == 1
assert resp["ops"][0]["action"] == "ADD"
assert len(resp["experience_ids"]) == 1
# 验证持久化
experience_id = resp["experience_ids"][0]
got = await coding_store.get_by_id(experience_id, user_id="u1")
assert got is not None
assert got.task == "打包发布到私有包索引"
assert got.search_keys == ["凭证位置", "上传命令"]
assert got.boundary_envs.startswith("twine>=6")
assert got.workspace_id == "github.com/x/y"
assert got.files == ["scripts/publish_package.sh"]
async def test_value_bar_returns_empty(self, coding_store, mock_embed):
"""extractor 返回 0 条 memory(价值门槛未达)"""
llm = MockLLMProvider(_extract_resp([]))
ext = CodingMemoryExtractor(llm)
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
writer = CodingWriter(
store=coding_store, extractor=ext, reconciler=rec,
llm_provider=llm, embed_service=mock_embed,
)
resp = await writer.write(
_coding_messages(), user_id="u1", workspace_id="ws/a",
)
assert resp["success"] is True
assert resp["ops"] == []
assert resp["experience_ids"] == []
async def test_no_workspace_still_writes(self, coding_store, mock_embed):
"""缺 workspace_id 不再拦稿,两条 draft 都应落库"""
ext_resp = _extract_resp([
{"task": "T1", "solution": "S", "confidence": 0.9},
{"task": "T2", "solution": "S", "confidence": 0.9},
])
rec_resp = _reconcile_resp([
{"draft_idx": 0, "action": "ADD"},
{"draft_idx": 1, "action": "ADD"},
])
llm = MockLLMProvider([ext_resp, rec_resp])
ext = CodingMemoryExtractor(llm)
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
writer = CodingWriter(
store=coding_store, extractor=ext, reconciler=rec,
llm_provider=llm, embed_service=mock_embed,
)
resp = await writer.write(
_coding_messages(), user_id="u1", workspace_id=None,
)
assert len(resp["experience_ids"]) == 2
assert sorted(await coding_store.list_user_tasks("u1")) == ["T1", "T2"]
async def test_extractor_failure(self, coding_store, mock_embed):
"""extractor LLM 失败 → success=False / LLM_ERROR,什么都不存,trace 也不追加"""
ext = CodingMemoryExtractor(FailingLLMProvider())
rec = CodingMemoryReconciler(coding_store, mock_embed, MockLLMProvider("{}"))
writer = CodingWriter(
store=coding_store, extractor=ext, reconciler=rec,
llm_provider=FailingLLMProvider(), embed_service=mock_embed,
)
resp = await writer.write(
_coding_messages(), user_id="u1", workspace_id="ws", session_id="s1",
)
assert resp["success"] is False
assert resp["error_code"] == 502
assert resp["agent_error_code"] == "LLM_ERROR"
assert resp["error_message"].startswith("[LLM_ERROR]")
assert resp["ops"] == [] and resp["experience_ids"] == []
assert resp["fact_ids"] == []
assert await coding_store.list_user_tasks("u1") == []
assert await coding_store.get_recent_trace_messages(
user_id="u1", agent_id="default_agent", max_user_turns=5,
) == [], "失败的写入不追加 trace:重跑时历史里不会多出这段"
async def test_extractor_failure_then_rerun_stores(self, coding_store, mock_embed):
"""LLM 恢复后换 request_id 重跑,正常落库。"""
failing = CodingMemoryExtractor(FailingLLMProvider())
rec = CodingMemoryReconciler(coding_store, mock_embed, MockLLMProvider("{}"))
writer = CodingWriter(
store=coding_store, extractor=failing, reconciler=rec,
llm_provider=FailingLLMProvider(), embed_service=mock_embed,
)
first = await writer.write(
_coding_messages(), user_id="u1", session_id="s1", request_id="r1",
)
assert first["success"] is False
llm = MockLLMProvider([
_extract_resp([{"task": "T1", "solution": "S", "confidence": 0.9}]),
_reconcile_resp([{"draft_idx": 0, "action": "ADD"}]),
])
writer.extractor = CodingMemoryExtractor(llm)
writer.reconciler = CodingMemoryReconciler(coding_store, mock_embed, llm)
second = await writer.write(
_coding_messages(), user_id="u1", session_id="s1", request_id="r2",
)
assert second["success"] is True
assert await coding_store.list_user_tasks("u1") == ["T1"]
async def test_extractor_empty_reply_is_failure(self, coding_store, mock_embed):
llm = MockLLMProvider("")
writer = CodingWriter(
store=coding_store, extractor=CodingMemoryExtractor(llm),
reconciler=CodingMemoryReconciler(coding_store, mock_embed, llm),
llm_provider=llm, embed_service=mock_embed,
)
resp = await writer.write(_coding_messages(), user_id="u1", session_id="s1")
assert resp["success"] is False
assert resp["agent_error_code"] == "EMPTY_RESPONSE"
async def test_experience_embedding_failure_is_failure(
self, coding_config, mock_embed,
):
"""经验向量化失败 → EMBEDDING_FAILED,SQLite 里也不留没有向量的经验。"""
from hy_memory.coding.store import CodingMemoryStore
store = CodingMemoryStore(coding_config, embed_service=_FailingEmbed())
await store.initialize()
try:
llm = MockLLMProvider([
_extract_resp([{"task": "T1", "solution": "S", "confidence": 0.9}]),
_reconcile_resp([{"draft_idx": 0, "action": "ADD"}]),
])
writer = CodingWriter(
store=store, extractor=CodingMemoryExtractor(llm),
reconciler=CodingMemoryReconciler(store, _FailingEmbed(), llm),
llm_provider=llm, embed_service=_FailingEmbed(),
)
resp = await writer.write(
_coding_messages(), user_id="u1", session_id="s1",
)
assert resp["success"] is False
assert resp["agent_error_code"] == "EMBEDDING_FAILED"
assert resp["experience_ids"] == []
assert await store.list_user_tasks("u1") == []
finally:
await store.close()
async def test_fact_embedding_failure_is_failure(self, coding_config, mock_embed):
"""事实向量化失败 → EMBEDDING_FAILED,事实不落库。"""
from hy_memory.coding.store import CodingMemoryStore
store = CodingMemoryStore(coding_config, embed_service=_FailingEmbed())
await store.initialize()
try:
llm = MockLLMProvider(_extract_resp([], {"statement": "构建产物在 dist/"}))
writer = CodingWriter(
store=store, extractor=CodingMemoryExtractor(llm),
reconciler=CodingMemoryReconciler(store, _FailingEmbed(), llm),
llm_provider=llm, embed_service=_FailingEmbed(),
)
resp = await writer.write(
_coding_messages(), user_id="u1", session_id="s1",
)
assert resp["success"] is False
assert resp["agent_error_code"] == "EMBEDDING_FAILED"
assert resp["fact_ids"] == []
with store._lock:
n = store._conn.execute(
"SELECT COUNT(*) FROM working_fact WHERE user_id=?", ("u1",),
).fetchone()[0]
assert n == 0
finally:
await store.close()
async def test_existing_tasks_not_shown_to_extractor(self, coding_store, mock_embed):
"""库里已有的 task 不得进抽取 prompt——抽取只读这一段,去重是 reconcile 的事"""
from hy_memory.coding.types import CodingMemory
await coding_store.insert(CodingMemory(
experience_id="e_old", user_id="u1", task="老 task", solution="S",
))
llm = MockLLMProvider([_extract_resp([]), _reconcile_resp([])])
ext = CodingMemoryExtractor(llm)
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
writer = CodingWriter(
store=coding_store, extractor=ext, reconciler=rec,
llm_provider=llm, embed_service=mock_embed,
)
await writer.write(_coding_messages(), user_id="u1", workspace_id="ws/a")
assert "老 task" not in llm.calls[0]["prompt"]
def _count_rows(store, sql, *args):
with store._lock:
return store._conn.execute(sql, args).fetchone()[0]
def _writer_with(store, embed, *, extract_llm, exp_llm, fact_llm):
from hy_memory.coding.fact_reconciler import WorkingFactReconciler
return CodingWriter(
store=store,
extractor=CodingMemoryExtractor(extract_llm),
reconciler=CodingMemoryReconciler(store, embed, exp_llm),
fact_reconciler=WorkingFactReconciler(store, embed, fact_llm),
llm_provider=extract_llm,
embed_service=embed,
)
_TWO_LAYER_EXTRACT = _extract_resp(
[{"task": "T1", "solution": "S", "confidence": 0.9}],
{"statement": "config 位于 /etc/app/config.yaml"},
)
@pytest.mark.asyncio
class TestReconcileDecisionFailure:
"""经验层 / 事实层 reconcile 的模型决策失败:整次写入 success=False、
agent_error_code=RECONCILE_FAILED,本次一条经验 / 事实都不落库,也不覆盖旧事实。"""
@pytest.mark.parametrize("exp_llm,cause", [
(FailingLLMProvider(), "LLM_UNAVAILABLE"),
(MockLLMProvider(""), "EMPTY_RESPONSE"),
(MockLLMProvider("not json at all"), "JSON_PARSE_FAILED"),
])
async def test_experience_reconcile_failure_stores_nothing(
self, coding_store, mock_embed, exp_llm, cause,
):
writer = _writer_with(
coding_store, mock_embed,
extract_llm=MockLLMProvider(_TWO_LAYER_EXTRACT),
exp_llm=exp_llm,
fact_llm=MockLLMProvider(_reconcile_resp([])),
)
resp = await writer.write(_coding_messages(), user_id="u1", session_id="s1")
assert resp["success"] is False
assert resp["error_code"] == 502
assert resp["agent_error_code"] == "RECONCILE_FAILED"
assert resp["reconcile_error_code"] == cause
assert resp["error_message"].startswith(f"[RECONCILE_FAILED] {cause}")
assert resp["experience_ids"] == [] and resp["fact_ids"] == []
assert await coding_store.list_user_tasks("u1") == []
assert _count_rows(
coding_store, "SELECT COUNT(*) FROM working_fact WHERE user_id=?", "u1",
) == 0, "事实层的决策虽然没问题,也不能先落库"
assert await coding_store.get_recent_trace_messages(
user_id="u1", agent_id="default_agent", max_user_turns=5,
) == []
async def test_valid_no_ops_reply_is_success(self, coding_store, mock_embed):
"""经验层 {"ops": []}:有效的「无需任何操作」,写入成功,经验层不存。"""
writer = _writer_with(
coding_store, mock_embed,
extract_llm=MockLLMProvider(_TWO_LAYER_EXTRACT),
exp_llm=MockLLMProvider(_reconcile_resp([])),
fact_llm=MockLLMProvider(_reconcile_resp([])),
)
resp = await writer.write(_coding_messages(), user_id="u1", session_id="s1")
assert resp["success"] is True
assert resp["ops"] == [] and resp["experience_ids"] == []
assert await coding_store.list_user_tasks("u1") == []
assert len(resp["fact_ids"]) == 1, "事实层照常插入"
@pytest.mark.parametrize("fact_llm,cause", [
(FailingLLMProvider(), "LLM_UNAVAILABLE"),
(MockLLMProvider(""), "EMPTY_RESPONSE"),
(MockLLMProvider("不是 JSON"), "JSON_PARSE_FAILED"),
])
async def test_fact_reconcile_failure_stores_nothing(
self, coding_store, mock_embed, fact_llm, cause,
):
"""事实层覆盖判定失败:新事实不插入、旧事实不被覆盖,经验层也不先落库。"""
from hy_memory.coding.types import WorkingFact
await coding_store.insert_fact(WorkingFact(
fact_id="f_old", user_id="u1", agent_id="default_agent",
session_id="s1", statement="config 位于 /opt/app/config.yaml",
))
writer = _writer_with(
coding_store, mock_embed,
extract_llm=MockLLMProvider(_TWO_LAYER_EXTRACT),
exp_llm=MockLLMProvider(_reconcile_resp([{"draft_idx": 0, "action": "ADD"}])),
fact_llm=fact_llm,
)
resp = await writer.write(_coding_messages(), user_id="u1", session_id="s1")
assert resp["success"] is False
assert resp["agent_error_code"] == "RECONCILE_FAILED"
assert resp["reconcile_error_code"] == cause
assert await coding_store.list_user_tasks("u1") == [], "经验层不得先落库"
assert _count_rows(
coding_store, "SELECT COUNT(*) FROM working_fact WHERE user_id=?", "u1",
) == 1, "只剩旧事实,新事实没插入"
old = await coding_store.get_fact("f_old", user_id="u1")
assert old.status == "active", "旧事实不得被覆盖"
async def test_rerun_with_new_request_id_after_recovery(
self, coding_store, mock_embed,
):
writer = _writer_with(
coding_store, mock_embed,
extract_llm=MockLLMProvider(_TWO_LAYER_EXTRACT),
exp_llm=FailingLLMProvider(),
fact_llm=MockLLMProvider(_reconcile_resp([])),
)
first = await writer.write(
_coding_messages(), user_id="u1", session_id="s1", request_id="r1",
)
assert first["success"] is False
writer.reconciler = CodingMemoryReconciler(
coding_store, mock_embed,
MockLLMProvider(_reconcile_resp([{"draft_idx": 0, "action": "ADD"}])),
)
second = await writer.write(
_coding_messages(), user_id="u1", session_id="s1", request_id="r2",
)
assert second["success"] is True
assert await coding_store.list_user_tasks("u1") == ["T1"]
assert len(second["fact_ids"]) == 1
assert _count_rows(
coding_store, "SELECT COUNT(*) FROM working_fact WHERE user_id=?", "u1",
) == 1
@pytest.mark.asyncio
class TestDualLayerWrite:
"""双层写入 + 同批全互链(§4)"""
def _writer(self, store, embed, llm, *, verbatim_fallback_enabled=False):
return CodingWriter(
store=store,
extractor=CodingMemoryExtractor(
llm,
verbatim_fallback_enabled=verbatim_fallback_enabled,
),
reconciler=CodingMemoryReconciler(store, embed, llm),
llm_provider=llm, embed_service=embed,
)
async def test_both_layers_written_and_crosslinked(self, coding_store, mock_embed):
llm = MockLLMProvider([
_extract_resp(
[{"task": "打包发布到私有包索引", "solution": "S", "confidence": 0.9}],
[{"statement": "发布脚本在 scripts/publish_package.sh", "files": ["scripts/publish_package.sh"]}],
),
_reconcile_resp([{"draft_idx": 0, "action": "ADD"}]),
])
writer = self._writer(coding_store, mock_embed, llm)
resp = await writer.write(
_coding_messages(), user_id="u1", workspace_id="ws/a", session_id="s1",
)
assert len(resp["experience_ids"]) == 1
assert len(resp["fact_ids"]) == 1
assert resp["fact_ops_summary"] == {"insert": 1, "supersede": 0, "total": 1}
eid, fid = resp["experience_ids"][0], resp["fact_ids"][0]
exp = await coding_store.get_by_id(eid, user_id="u1")
fact = await coding_store.get_fact(fid, user_id="u1")
assert exp.source_fact_ids == [fid]
assert fact.derived_experience_ids == [eid]
assert fact.statement == "发布脚本在 scripts/publish_package.sh"
async def test_all_experiences_linked_to_unique_fact(self, coding_store, mock_embed):
"""一个 trace 的多条经验都与唯一会话 fact 双向关联。"""
llm = MockLLMProvider([
_extract_resp(
[{"task": "T1", "solution": "S"}, {"task": "T2", "solution": "S"}],
[{"statement": "事实 A"}, {"statement": "事实 B"}],
),
_reconcile_resp([
{"draft_idx": 0, "action": "ADD"},
{"draft_idx": 1, "action": "ADD"},
]),
])
writer = self._writer(coding_store, mock_embed, llm)
resp = await writer.write(_coding_messages(), user_id="u1")
eids, fids = resp["experience_ids"], resp["fact_ids"]
assert len(eids) == 2 and len(fids) == 1
for eid in eids:
exp = await coding_store.get_by_id(eid, user_id="u1")
assert exp.source_fact_ids == fids
fact = await coding_store.get_fact(fids[0], user_id="u1")
assert sorted(fact.derived_experience_ids) == sorted(eids)
async def test_skip_not_linked(self, coding_store, mock_embed):
"""SKIP 的经验没真入库,不该被连上这批事实。"""
llm = MockLLMProvider([
_extract_resp(
[{"task": "T1", "solution": "S"}],
[{"statement": "事实 A"}],
),
_reconcile_resp([{"draft_idx": 0, "action": "SKIP", "reason": "dup"}]),
])
writer = self._writer(coding_store, mock_embed, llm)
resp = await writer.write(_coding_messages(), user_id="u1")
assert resp["experience_ids"] == []
assert len(resp["fact_ids"]) == 1
fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u1")
assert fact.derived_experience_ids == []
async def test_update_keeps_old_fact_link(self, coding_store, mock_embed):
"""UPDATE 覆盖正文不留痕,但事实链取并集,旧链要保住。"""
from hy_memory.coding.types import CodingMemory
await coding_store.insert(CodingMemory(
experience_id="e1", user_id="u1", task="老 task", solution="旧 solution",
source_fact_ids=["f_before"], session_id="test-session",
))
llm = MockLLMProvider([
_extract_resp(
[{"task": "老 task", "solution": "新 solution"}],
[{"statement": "本轮新事实"}],
),
_reconcile_resp([
{"draft_idx": 0, "action": "UPDATE", "target_experience_id": "e1"},
]),
])
writer = self._writer(coding_store, mock_embed, llm)
resp = await writer.write(
_coding_messages(), user_id="u1", session_id="test-session"
)
exp = await coding_store.get_by_id("e1", user_id="u1")
assert exp.solution == "新 solution"
assert exp.source_fact_ids == ["f_before", resp["fact_ids"][0]]
async def test_facts_only_no_experience(self, coding_store, mock_embed):
"""经验层空、事实层非空:事实照样落库,不触发经验层 reconcile。"""
llm = MockLLMProvider(_extract_resp([], [{"statement": "只有事实"}]))
writer = self._writer(coding_store, mock_embed, llm)
resp = await writer.write(_coding_messages(), user_id="u1")
assert resp["experience_ids"] == []
assert len(resp["fact_ids"]) == 1
# extract 一次;事实层库里没候选所以不调 LLM;经验层 reconcile 完全没跑
assert len(llm.calls) == 1
async def test_empty_experiences_do_not_persist_verbatim_fact_by_default(
self, coding_store, mock_embed,
):
llm = MockLLMProvider(_extract_resp([], []))
writer = self._writer(coding_store, mock_embed, llm)
resp = await writer.write(_coding_messages(), user_id="u1")
assert resp["ops"] == []
assert resp["fact_ids"] == []
assert resp["fact_ops"] == []
async def test_empty_experiences_can_persist_verbatim_fact_when_enabled(
self, coding_store, mock_embed,
):
"""显式开启时,两层都抽空仍可落一条原文事实。
端到端确认开关开启后,fallback 能穿过 reconcile 真正落库。
"""
llm = MockLLMProvider(_extract_resp([], []))
writer = self._writer(
coding_store,
mock_embed,
llm,
verbatim_fallback_enabled=True,
)
resp = await writer.write(_coding_messages(), user_id="u1")
assert resp["ops"] == [] # 经验层不兜底
assert len(resp["fact_ids"]) == 1
assert [op["action"] for op in resp["fact_ops"]] == ["INSERT"]
fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u1")
assert fact.statement == "帮我打包发布到私有包索引"
assert "twine upload" in fact.details # 轨迹原文进了 details