Files
Hy-Memory/tests/coding/test_reconciler.py
T
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

290 lines
11 KiB
Python
Raw 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 -*-
"""Reconciler 测试 — ADD / UPDATE / DELETE / SKIP / fallback"""
import json
import pytest
from hy_memory.coding.reconciler import CodingMemoryReconciler
from hy_memory.coding.types import CodingMemory, CodingMemoryDraft, CodingWriteError
from tests.coding._helpers import MockLLMProvider, FailingLLMProvider
def _llm_resp(ops):
return json.dumps({"ops": ops})
@pytest.mark.asyncio
class TestReconcilerActions:
async def test_all_add_on_empty_store(self, coding_store, mock_embed):
llm = MockLLMProvider(_llm_resp([
{"draft_idx": 0, "action": "ADD"},
{"draft_idx": 1, "action": "ADD"},
]))
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
d1 = CodingMemoryDraft(task="T1", solution="S1", user_id="u1")
d2 = CodingMemoryDraft(task="T2", solution="S2", user_id="u1")
ops = await rec.reconcile([d1, d2])
assert len(ops) == 2
assert all(op.action == "ADD" for op in ops)
assert all(op.target_experience_id for op in ops)
tasks = await coding_store.list_user_tasks("u1", 10)
assert sorted(tasks) == ["T1", "T2"]
async def test_update(self, coding_store, mock_embed):
# 种入 m1
await coding_store.insert(CodingMemory(
experience_id="m1", user_id="u1", agent_id="scope-day-1",
workspace_id="repo-a", session_id="conversation-1", task="T1",
search_keys=["old"], solution="old",
))
llm = MockLLMProvider(_llm_resp([
{"draft_idx": 0, "action": "UPDATE",
"target_experience_id": "m1", "reason": "newer solution"},
]))
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
d = CodingMemoryDraft(task="T1", search_keys=["new1"], solution="new",
user_id="u1", agent_id="scope-day-1",
workspace_id="repo-a", session_id="conversation-1")
ops = await rec.reconcile([d])
assert len(ops) == 1 and ops[0].action == "UPDATE"
got = await coding_store.get_by_id("m1", "u1")
assert got.solution == "new"
assert got.search_keys == ["new1"]
@pytest.mark.parametrize(
(
"old_agent",
"old_workspace",
"old_session",
"new_agent",
"new_workspace",
"new_session",
),
[
(
"scope:same-day",
"repo-a",
"conversation-a",
"scope:same-day",
"repo-a",
"conversation-b",
),
(
"scope:2026-10-18",
"repo-a",
"conversation-a",
"scope:2026-10-19",
"repo-a",
"conversation-a",
),
(
"shared-scope",
"repo-a",
"conversation-a",
"shared-scope",
"repo-b",
"conversation-a",
),
],
)
async def test_update_target_must_match_frozen_authority_scope(
self,
coding_store,
mock_embed,
old_agent,
old_workspace,
old_session,
new_agent,
new_workspace,
new_session,
):
"""Near duplicates cannot cross conversation, date, or workspace authority."""
await coding_store.insert(CodingMemory(
experience_id="old-target", user_id="u1", agent_id=old_agent,
workspace_id=old_workspace, session_id=old_session,
task="fix flaky release", solution="old solution",
))
llm = MockLLMProvider(_llm_resp([
{
"draft_idx": 0,
"action": "UPDATE",
"target_experience_id": "old-target",
"reason": "near duplicate",
},
]))
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
draft = CodingMemoryDraft(
task="fix flaky release",
solution="new solution",
user_id="u1",
agent_id=new_agent,
workspace_id=new_workspace,
session_id=new_session,
)
ops = await rec.reconcile([draft])
old = await coding_store.get_by_id("old-target", "u1")
assert old is not None
assert old.solution == "old solution"
assert old.agent_id == old_agent
assert old.workspace_id == old_workspace
assert old.session_id == old_session
assert len(ops) == 1
assert ops[0].action == "ADD"
assert ops[0].target_experience_id != "old-target"
@pytest.mark.parametrize("legacy_agent", ["", "default_agent", "scope:legacy"])
async def test_legacy_target_without_conversation_falls_back_to_add(
self, coding_store, mock_embed, legacy_agent
):
await coding_store.insert(CodingMemory(
experience_id="legacy-target",
user_id="u1",
agent_id=legacy_agent,
session_id=None,
workspace_id="repo-a",
task="upgrade dependency",
solution="legacy solution",
))
llm = MockLLMProvider(_llm_resp([
{
"draft_idx": 0,
"action": "UPDATE",
"target_experience_id": "legacy-target",
},
]))
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
draft = CodingMemoryDraft(
task="upgrade dependency",
solution="current solution",
user_id="u1",
agent_id=legacy_agent,
session_id="current-conversation",
workspace_id="repo-a",
)
ops = await rec.reconcile([draft])
legacy = await coding_store.get_by_id("legacy-target", "u1")
assert legacy is not None
assert legacy.solution == "legacy solution"
assert legacy.session_id is None
assert [op.action for op in ops] == ["ADD"]
async def test_skip(self, coding_store, mock_embed):
await coding_store.insert(CodingMemory(
experience_id="m1", user_id="u1", task="X", solution="Y",
session_id="conversation-1",
))
llm = MockLLMProvider(_llm_resp([
{"draft_idx": 0, "action": "SKIP", "reason": "duplicate"},
]))
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
d = CodingMemoryDraft(task="X", solution="Y",
user_id="u1", session_id="conversation-1")
ops = await rec.reconcile([d])
assert len(ops) == 1 and ops[0].action == "SKIP"
# store 不变
tasks = await coding_store.list_user_tasks("u1", 10)
assert tasks == ["X"]
async def test_add_with_delete_multi_op(self, coding_store, mock_embed):
# 旧记忆要被 DELETE
await coding_store.insert(CodingMemory(
experience_id="m_dep", user_id="u1", task="use old.sh",
solution="old script", session_id="conversation-1",
))
llm = MockLLMProvider(_llm_resp([
{"draft_idx": 0, "action": "ADD"},
{"draft_idx": 0, "action": "DELETE", "target_experience_id": "m_dep",
"reason": "user explicitly deprecated old.sh"},
]))
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
d = CodingMemoryDraft(task="use new CLI", solution="new way",
user_id="u1", session_id="conversation-1")
ops = await rec.reconcile([d])
assert sorted(op.action for op in ops) == ["ADD", "DELETE"]
# 旧的没了
assert await coding_store.get_by_id("m_dep", "u1") is None
# 新的进了
tasks = await coding_store.list_user_tasks("u1", 10)
assert "use new CLI" in tasks
assert "use old.sh" not in tasks
@pytest.mark.asyncio
class TestReconcilerFallback:
@pytest.mark.parametrize("llm,cause", [
(FailingLLMProvider(), "LLM_UNAVAILABLE"),
(MockLLMProvider(""), "EMPTY_RESPONSE"),
(MockLLMProvider("not json"), "JSON_PARSE_FAILED"),
(MockLLMProvider("{}"), "JSON_PARSE_FAILED"),
(MockLLMProvider('{"ops": [{"draft_idx": 0, "action": "MERGE"}]}'),
"JSON_PARSE_FAILED"),
])
async def test_decision_failure_raises_and_stores_nothing(
self, coding_store, mock_embed, llm, cause,
):
"""LLM 失败 / 空回复 / 解析不出不再回退成 ADD-all:抛 RECONCILE_FAILED。"""
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
d1 = CodingMemoryDraft(task="T1", solution="S", user_id="u1")
d2 = CodingMemoryDraft(task="T2", solution="S", user_id="u1")
with pytest.raises(CodingWriteError) as exc:
await rec.reconcile([d1, d2])
assert exc.value.error_code == "RECONCILE_FAILED"
assert exc.value.cause == cause
assert await coding_store.list_user_tasks("u1") == []
async def test_valid_empty_ops_is_a_no_change_decision(
self, coding_store, mock_embed,
):
"""{"ops": []} 与 chat 的 [] 同义:成功、什么都不存。"""
rec = CodingMemoryReconciler(
coding_store, mock_embed, MockLLMProvider(_llm_resp([])),
)
d = CodingMemoryDraft(task="T", solution="S", user_id="u1")
ops = await rec.reconcile([d])
assert ops == []
assert await coding_store.list_user_tasks("u1") == []
async def test_missing_draft_idx_fallback_add(self, coding_store, mock_embed):
# LLM 只处理了 draft 0,draft 1 漏掉
llm = MockLLMProvider(_llm_resp([{"draft_idx": 0, "action": "ADD"}]))
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
d1 = CodingMemoryDraft(task="T1", solution="S", user_id="u1")
d2 = CodingMemoryDraft(task="T2", solution="S", user_id="u1")
ops = await rec.reconcile([d1, d2])
# T1 来自 LLM ADD;T2 来自 fallback ADD
actions = [op.action for op in ops]
assert actions.count("ADD") == 2
async def test_invalid_op_filtered(self, coding_store, mock_embed):
# UPDATE 缺 target_experience_id 应被丢弃,draft_idx=0 再走 fallback ADD
llm = MockLLMProvider(_llm_resp([
{"draft_idx": 0, "action": "UPDATE"}, # 缺 target → 丢弃
]))
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
d = CodingMemoryDraft(task="T", solution="S", user_id="u1")
ops = await rec.reconcile([d])
# fallback ADD
assert any(op.action == "ADD" for op in ops)
async def test_empty_drafts(self, coding_store, mock_embed):
rec = CodingMemoryReconciler(coding_store, mock_embed, MockLLMProvider("{}"))
ops = await rec.reconcile([])
assert ops == []
async def test_plan_does_not_store(self, coding_store, mock_embed):
"""plan() 只做检索 + 决策;apply() 之前库里什么都没有。"""
llm = MockLLMProvider(_llm_resp([{"draft_idx": 0, "action": "ADD"}]))
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
d = CodingMemoryDraft(task="T", solution="S", user_id="u1")
plan = await rec.plan([d])
assert [op.action for op in plan.ops] == ["ADD"]
assert await coding_store.list_user_tasks("u1") == []
await rec.apply(plan)
assert await coding_store.list_user_tasks("u1") == ["T"]