574 lines
26 KiB
Python
574 lines
26 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
scene="coding" —— coding 链路的软件工程变体。
|
|
|
|
与 scene="working" 的关系: 同一条链路(同一个 store / 表 / reconciler), 差别只有两处,
|
|
本文件按这两处分组:
|
|
1. 抽取规范 —— EXTRACT_PROMPT_CODING(TestPromptSelection / TestPromptContent)
|
|
2. 版本演进链层 —— chain_store(TestChainObserve / TestChainDecorate)
|
|
|
|
外加路由本身(TestSceneRouting)和"不传 scene 行为不变"的回归闸门(TestWorkingUnchanged)。
|
|
|
|
⚠️ 这里全部是零 LLM / 零 embedding 的测试。链层本身不调模型, 抽取规范只验"选了哪份
|
|
prompt", 不验模型读了之后的行为 —— 那个要上 CodeMemoryBench 跑。
|
|
"""
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
from hy_memory.client import HyMemoryClient
|
|
from hy_memory.coding.chain_store import HyCodeMem
|
|
from hy_memory.coding.extractor import (
|
|
EXTRACT_PROMPT,
|
|
EXTRACT_PROMPT_CODING,
|
|
CodingMemoryExtractor,
|
|
)
|
|
|
|
|
|
class _FakeMsg:
|
|
"""最小 ChatMessage 替身 —— 只要 _chain_text 读的那几个字段。"""
|
|
|
|
def __init__(self, role, content, tool_name=None):
|
|
self.role = role
|
|
self.content = content
|
|
self.tool_name = tool_name
|
|
self.tool_calls = None
|
|
|
|
def is_tool_message(self):
|
|
return self.role == "tool"
|
|
|
|
|
|
class _FakeProvider:
|
|
config = None
|
|
_llm_config = None
|
|
|
|
|
|
def _edit_msgs(said, old, new, path="qutebrowser/config/configtypes.py"):
|
|
"""一轮"用户说要改 + agent 写文件"。
|
|
|
|
工具消息写成 `path=/old=/new=` 三段是刻意的: 链层在 git 反查落空时靠这个形态
|
|
合成 diff(_patch_from_edits)。线上大多数场景给不了本地仓路径, 走的就是这条。
|
|
"""
|
|
return [
|
|
_FakeMsg("user", said),
|
|
_FakeMsg(
|
|
"tool",
|
|
f"path='{path}'\nold='''{old}'''\nnew='''{new}'''",
|
|
tool_name="edit",
|
|
),
|
|
]
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 1. 路由
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestSceneRouting:
|
|
def test_coding_routes_to_coding_chain(self):
|
|
# "coding" 和 "working" 归一成同一个路由值 —— add()/search() 里所有
|
|
# `scene_forced == "coding"` 的判断对两者同时生效, 不必逐处加分支。
|
|
assert HyMemoryClient._normalize_scene("coding") == "coding"
|
|
assert HyMemoryClient._normalize_scene("working") == "coding"
|
|
|
|
def test_chat_unaffected(self):
|
|
assert HyMemoryClient._normalize_scene("chat") == "chat"
|
|
|
|
def test_none_stays_auto(self):
|
|
assert HyMemoryClient._normalize_scene(None) is None
|
|
|
|
def test_case_and_whitespace_insensitive(self):
|
|
assert HyMemoryClient._normalize_scene(" CODING ") == "coding"
|
|
assert HyMemoryClient._scene_variant(" Coding ") == "coding"
|
|
|
|
def test_illegal_value_raises(self):
|
|
with pytest.raises(ValueError) as ei:
|
|
HyMemoryClient._normalize_scene("productivity")
|
|
# 报错要把三个合法值都列出来 —— 只说 'chat' or 'working' 会让人以为
|
|
# coding 不存在。
|
|
assert "coding" in str(ei.value)
|
|
|
|
def test_variant_is_orthogonal_to_route(self):
|
|
# 同一个路由值("coding")下两个变体; 变体只由 _scene_variant 决定。
|
|
assert HyMemoryClient._scene_variant("coding") == "coding"
|
|
assert HyMemoryClient._scene_variant("working") == "working"
|
|
assert HyMemoryClient._scene_variant("chat") == "working"
|
|
assert HyMemoryClient._scene_variant(None) == "working"
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 2. 抽取规范的选择
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestPromptSelection:
|
|
def test_default_is_working(self):
|
|
# ★ 回归闸门: 不传 scene 必须还是 working 那份。已落盘的 working 评测
|
|
# 靠这条保证不被动。
|
|
e = CodingMemoryExtractor(_FakeProvider())
|
|
assert e.scene == "working"
|
|
assert e._prompt_tmpl is EXTRACT_PROMPT
|
|
|
|
def test_coding_picks_coding_prompt(self):
|
|
e = CodingMemoryExtractor(_FakeProvider(), scene="coding")
|
|
assert e.scene == "coding"
|
|
assert e._prompt_tmpl is EXTRACT_PROMPT_CODING
|
|
|
|
def test_unknown_scene_falls_back_to_working(self):
|
|
# 不抛错: 抽取是一次性的, 这段轨迹不会再回来, 为一个拼错的参数把整段丢掉
|
|
# 不值得(同 extract() 里 LLM 失败走原文兜底的取舍)。
|
|
e = CodingMemoryExtractor(_FakeProvider(), scene="bogus")
|
|
assert e.scene == "working"
|
|
assert e._prompt_tmpl is EXTRACT_PROMPT
|
|
|
|
def test_both_prompts_share_placeholders(self):
|
|
# 两份必须能被同一份 .format() 参数填 —— extract() 只有一处 format 调用。
|
|
kw = dict(workspace_present="true", branch_present="true",
|
|
conversation_block="x")
|
|
assert EXTRACT_PROMPT.format(**kw)
|
|
assert EXTRACT_PROMPT_CODING.format(**kw)
|
|
|
|
|
|
class TestPromptContent:
|
|
"""两份规范各自独有的段落。钉住的是**内容边界**, 不是措辞质量。"""
|
|
|
|
def test_coding_has_hard_constraint_header(self):
|
|
# 前置覆盖后文任何"想要叙事"的措辞。
|
|
assert "HARD CONSTRAINT" in EXTRACT_PROMPT_CODING
|
|
assert "HARD CONSTRAINT" not in EXTRACT_PROMPT
|
|
# 三条核心要求
|
|
assert "FUTURE AGENT needs in order to act" in EXTRACT_PROMPT_CODING
|
|
assert "never \"the user\"" in EXTRACT_PROMPT_CODING
|
|
assert "the session was completed" in EXTRACT_PROMPT_CODING
|
|
|
|
def test_coding_has_code_work_section(self):
|
|
assert "CODE WORK" in EXTRACT_PROMPT_CODING
|
|
assert "CODE WORK" not in EXTRACT_PROMPT
|
|
# 四件未来 agent 无法从代码反推的东西
|
|
assert "BRANCH / WORKING LOCATION" in EXTRACT_PROMPT_CODING
|
|
assert "WHICH VERSION WON" in EXTRACT_PROMPT_CODING
|
|
assert "EXACT COORDINATES, NOT PASTED CODE" in EXTRACT_PROMPT_CODING
|
|
assert "VERIFIED vs MERELY WRITTEN" in EXTRACT_PROMPT_CODING
|
|
|
|
def test_coding_marks_superseded_version_phrases(self):
|
|
# 用户改主意的语言标记 —— 链层那边也认同一批词。
|
|
assert "scratch that" in EXTRACT_PROMPT_CODING
|
|
|
|
def test_working_keeps_the_ask_user_exclusion(self):
|
|
# working 独有: 卡在向用户要参数时强制 experiences: []。
|
|
# coding 侧刻意不带 —— 那边的轨迹是 agent 自主执行, 不存在这种停顿。
|
|
assert "ASKING THE USER" in EXTRACT_PROMPT
|
|
assert "ASKING THE USER" not in EXTRACT_PROMPT_CODING
|
|
|
|
def test_both_keep_evidence_fidelity(self):
|
|
# 这条是两份共有的底线: 只记真执行过的动作。任何一份丢了都是缺陷。
|
|
for p in (EXTRACT_PROMPT, EXTRACT_PROMPT_CODING):
|
|
assert "only actions actually executed" in p
|
|
assert "Never promote a partial search to" in p
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 3. 版本演进链层 —— 写
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
@pytest.fixture
|
|
def chain():
|
|
with tempfile.TemporaryDirectory() as d:
|
|
yield HyCodeMem(os.path.join(d, "chain"))
|
|
|
|
|
|
class TestChainText:
|
|
"""client._chain_text: messages → 链层能读的纯文本。"""
|
|
|
|
def test_renders_lowercase_role_prefixes(self):
|
|
# 形态必须对齐链层的 _USER_LC / _DIFF_END 认的那套小写前缀。
|
|
t = HyMemoryClient._chain_text(
|
|
[_FakeMsg("user", "hi"), _FakeMsg("assistant", "ok"),
|
|
_FakeMsg("tool", "out", tool_name="bash")]
|
|
)
|
|
assert t.startswith("user: hi")
|
|
assert "assistant: ok" in t
|
|
assert "[tool:bash] out" in t
|
|
|
|
def test_no_index_prefix(self):
|
|
# ⚠️ 不能复用 extractor._format_conversation —— 那个给每行加 `[i] ` 下标
|
|
# (给 LLM 读的), 而链层的正则锚在行首, 前缀一加全部匹配不上。
|
|
t = HyMemoryClient._chain_text([_FakeMsg("user", "hi")])
|
|
assert not t.startswith("[0]")
|
|
|
|
def test_tolerates_empty_and_missing_content(self):
|
|
t = HyMemoryClient._chain_text(
|
|
[_FakeMsg("user", None), _FakeMsg("assistant", "")]
|
|
)
|
|
assert isinstance(t, str)
|
|
|
|
def test_empty_input(self):
|
|
assert HyMemoryClient._chain_text([]) == ""
|
|
assert HyMemoryClient._chain_text(None) == ""
|
|
|
|
|
|
class TestChainObserve:
|
|
def test_extracts_patch_from_dialog_without_git(self):
|
|
# 没有本地仓也要能存下来 —— git 反查落空时从对话里的 old/new 合成。
|
|
# agent 修改文件后不一定立即提交,因此不能只依赖 commit diff。
|
|
with tempfile.TemporaryDirectory() as d:
|
|
H = HyCodeMem(os.path.join(d, "c")) # repo_root=None
|
|
n = H.observe(
|
|
HyMemoryClient._chain_text(
|
|
_edit_msgs("On branch feat/q4, set mult.",
|
|
"mult = 255.0\n", "mult = 360.0\n")),
|
|
meta={"branch": "feat/q4"},
|
|
)
|
|
assert n >= 1
|
|
assert H.stats()["threads"] == 1
|
|
|
|
def test_multiple_versions_land_on_one_thread(self, chain):
|
|
# ★ 这层存在的理由: 同一处代码被改了几版, 每版的原话 + 真实 patch 都留着。
|
|
for said, old, new in [
|
|
("On branch feat/q4, first pass: 255.0 everywhere.",
|
|
"mult = 255.0\n", "mult = 360.0\n"),
|
|
("Hold on, scratch that — special-case only hue.",
|
|
"mult = 360.0\n", "mult = 360.0 if kind == 'h' else 255.0\n"),
|
|
]:
|
|
chain.observe(HyMemoryClient._chain_text(_edit_msgs(said, old, new)),
|
|
meta={"branch": "feat/q4"})
|
|
st = chain.stats()
|
|
assert st["threads"] == 1, "同一符号的多版必须并到一条链, 否则版本序号会从 1 重数"
|
|
assert st["rounds"] == 2
|
|
assert st["multi"] == 1
|
|
|
|
def test_branch_from_meta_beats_guessing(self, chain):
|
|
chain.observe(
|
|
HyMemoryClient._chain_text(
|
|
_edit_msgs("no branch token here", "a = 1\n", "a = 2\n")),
|
|
meta={"branch": "fix/explicit"},
|
|
)
|
|
assert chain.stats()["branches"] == 1
|
|
|
|
def test_no_code_change_stores_nothing(self, chain):
|
|
# 纯对话没有可识别的改动 → 0。这是正常情况, 不是错误。
|
|
assert chain.observe(
|
|
HyMemoryClient._chain_text([_FakeMsg("user", "早上好")])) == 0
|
|
assert chain.stats()["rounds"] == 0
|
|
|
|
def test_empty_text_is_noop(self, chain):
|
|
assert chain.observe("") == 0
|
|
assert chain.observe(None) == 0
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 4. 版本演进链层 —— 读
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _seed_two_versions(H, branch="feat/q4",
|
|
path="qutebrowser/config/configtypes.py"):
|
|
for said, old, new in [
|
|
("On branch %s, first pass: 255.0 everywhere." % branch,
|
|
"mult = 255.0\n", "mult = 360.0\n"),
|
|
("Hold on, scratch that — special-case only hue.",
|
|
"mult = 360.0\n", "mult = 360.0 if kind == 'h' else 255.0\n"),
|
|
]:
|
|
H.observe(HyMemoryClient._chain_text(_edit_msgs(said, old, new, path=path)),
|
|
meta={"branch": branch})
|
|
|
|
|
|
_FACT = {
|
|
"statement": ("On branch feat/q4, in qutebrowser/config/configtypes.py "
|
|
"QtColor._parse_value: settled on the hue multiplier."),
|
|
"branch": "feat/q4",
|
|
"files": ["qutebrowser/config/configtypes.py"],
|
|
}
|
|
|
|
|
|
class TestChainDecorate:
|
|
def test_block_carries_branch_and_all_versions(self, chain):
|
|
_seed_two_versions(chain)
|
|
blk, used = chain.decorate([dict(_FACT)], instruction="which version won?")
|
|
assert used == [0]
|
|
# 分支名必须在块里 —— agent 在错分支上改的代码会被静默丢掉。
|
|
assert "feat/q4" in blk
|
|
# 两版都在, 不只留最后一版: 遇到被弃那版的代码要认得出来, 否则会"修"回去。
|
|
assert "第1版" in blk and "第2版" in blk
|
|
assert "255.0" in blk and "360.0" in blk
|
|
|
|
def test_fact_without_branch_falls_back_to_files(self, chain):
|
|
# 事实层的 fact 可能没有 branch 字段。branch 认不出就按 files 退回,
|
|
# 不能直接放弃,否则 diff 块会始终为空。
|
|
_seed_two_versions(chain)
|
|
fa = {"statement": "the hue multiplier decision",
|
|
"files": ["qutebrowser/config/configtypes.py"]}
|
|
blk, used = chain.decorate([fa], instruction="hue multiplier")
|
|
assert used == [0]
|
|
assert blk
|
|
|
|
def test_unrelated_fact_gets_no_block(self, chain):
|
|
_seed_two_versions(chain)
|
|
blk, used = chain.decorate(
|
|
[{"statement": "something else entirely", "files": ["other/file.py"]}],
|
|
instruction="x")
|
|
assert used == []
|
|
assert blk == ""
|
|
|
|
def test_empty_facts(self, chain):
|
|
assert chain.decorate([], instruction="x") == ("", [])
|
|
|
|
def test_block_off_switch_disables_layer(self, chain):
|
|
# 消融开关: 让 coding 退化成"纯向量双层"(= working 的召回形态), 用来做
|
|
# 单变量 A/B。CodeMemoryBench 消融靠它: 去掉这层 77/216 → 45/216。
|
|
_seed_two_versions(chain)
|
|
os.environ["HCM_BLOCK_OFF"] = "1"
|
|
try:
|
|
assert chain.decorate([dict(_FACT)], instruction="x") == ("", [])
|
|
finally:
|
|
os.environ.pop("HCM_BLOCK_OFF", None)
|
|
|
|
|
|
class TestDecorateGlue:
|
|
"""client._decorate_with_chain —— 胶水层的 fail-safe。"""
|
|
|
|
def test_returns_empty_when_chain_unavailable(self):
|
|
import types
|
|
ns = types.SimpleNamespace(
|
|
_chain_store=None, _chain_store_failed=True,
|
|
_get_chain_store=lambda owner="": None,
|
|
)
|
|
assert HyMemoryClient._decorate_with_chain(ns, [dict(_FACT)]) == ("", [])
|
|
|
|
def test_store_exception_degrades_not_raises(self):
|
|
# 这层是增量, 挂了要退回纯向量双层而不是把整次 search 拖失败。
|
|
import types
|
|
|
|
class _Boom:
|
|
def decorate(self, *a, **k):
|
|
raise RuntimeError("boom")
|
|
|
|
ns = types.SimpleNamespace(_get_chain_store=lambda owner="": _Boom())
|
|
assert HyMemoryClient._decorate_with_chain(ns, [dict(_FACT)]) == ("", [])
|
|
|
|
|
|
class TestObserveGlue:
|
|
def test_returns_zero_when_chain_unavailable(self):
|
|
import types
|
|
ns = types.SimpleNamespace(_get_chain_store=lambda owner="": None)
|
|
assert HyMemoryClient._observe_chain(ns, [_FakeMsg("user", "hi")]) == 0
|
|
|
|
def test_store_exception_degrades_not_raises(self):
|
|
import types
|
|
|
|
class _Boom:
|
|
def observe(self, *a, **k):
|
|
raise RuntimeError("boom")
|
|
|
|
ns = types.SimpleNamespace(_get_chain_store=lambda owner="": _Boom())
|
|
n = HyMemoryClient._observe_chain(
|
|
ns, _edit_msgs("x", "a = 1\n", "a = 2\n"))
|
|
assert n == 0
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 5. working 不受影响
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestWorkingUnchanged:
|
|
"""★ 整个改动的前提: working 路一个字节都不动。"""
|
|
|
|
def test_working_prompt_byte_length_pinned(self):
|
|
# 钉住长度而不是内容: 内容有自己的断言(tests/coding/test_extractor.py 30+ 条),
|
|
# 这里防的是"改 coding 时手滑动了 working 那份"。
|
|
#
|
|
# ⚠️ 这个数字必须用 `len(EXTRACT_PROMPT)` 实测得到, 不要从 diff / 正则抽取的
|
|
# 片段长度推 —— 正则取到的 body 不含尾换行等边界字符, 按它填会差几个字节,
|
|
# 于是这条断言在完全没改 working 的情况下也会红(我第一版就栽在这)。
|
|
assert len(EXTRACT_PROMPT) == 18517, (
|
|
"working 的抽取规范被改动了。它带着自己的实测结论(statement 长度三版对照, "
|
|
"见其正文), 动它要先复现那批 A/B。"
|
|
)
|
|
|
|
def test_coding_prompt_is_separate_object(self):
|
|
assert EXTRACT_PROMPT is not EXTRACT_PROMPT_CODING
|
|
|
|
def test_working_variant_does_not_touch_chain(self):
|
|
# variant="working" 时写入侧不该调链层 —— 否则 working 的库里会多出
|
|
# hy_code_chain 目录, 且行为与改动前不一致。
|
|
assert HyMemoryClient._scene_variant("working") == "working"
|
|
assert HyMemoryClient._scene_variant(None) == "working"
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 6. 两个变体的库分开
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestStoreSeparation:
|
|
"""★ working 和 coding 读写**不同的库**。
|
|
|
|
为什么必须分库而不是加一个 variant 列做过滤: 检索是**先**在向量集合里取 top-K、
|
|
**再**用 sqlite 过滤。两个变体的 fact 混在同一个集合里时, 向量那一步就已经混了 ——
|
|
后面加任何 SQL 条件都救不回来, 因为 top-K 的名额已经被另一个变体占掉了。
|
|
"""
|
|
|
|
def test_sqlite_path_diverges(self):
|
|
from hy_memory.coding.store import _variant_db_path
|
|
assert _variant_db_path("/d/coding_memory.db", "_cv") == "/d/coding_memory_cv.db"
|
|
|
|
def test_working_paths_byte_identical(self):
|
|
# ★ 回归闸门: working 的路径和集合名必须与加变体之前逐字相同, 否则已落盘的
|
|
# 库读不到(表现为"库空了")。
|
|
from hy_memory.coding.store import (
|
|
_variant_db_path, _derive_fact_collection_name,
|
|
_derive_coding_collection_name,
|
|
)
|
|
assert _variant_db_path("/d/coding_memory.db", "") == "/d/coding_memory.db"
|
|
assert _derive_fact_collection_name("am", "") == "am_working_facts"
|
|
assert _derive_coding_collection_name("am", "") == "am_coding_keys"
|
|
|
|
def test_collections_diverge(self):
|
|
from hy_memory.coding.store import (
|
|
_derive_fact_collection_name, _derive_coding_collection_name,
|
|
)
|
|
assert _derive_fact_collection_name("am", "_cv") == "am_working_facts_cv"
|
|
assert _derive_coding_collection_name("am", "_cv") == "am_coding_keys_cv"
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 7. 链层的数据域隔离
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestChainOwnerIsolation:
|
|
"""★ thread 的键必须含 owner。
|
|
|
|
没有它时 thread 键只有 (branch, file, pkey) —— 用户 A 的 feat/q4 和用户 B 的
|
|
feat/q4 会并成同一条链, A 能在注入块里读到 B 的 diff。这是数据泄漏。
|
|
"""
|
|
|
|
_FACT = [{"statement": "On branch feat/q4, cfg.py 改了什么",
|
|
"branch": "feat/q4", "files": ["cfg.py"]}]
|
|
|
|
@staticmethod
|
|
def _edit(said, old, new, path="cfg.py"):
|
|
return [
|
|
_FakeMsg("user", said),
|
|
_FakeMsg("tool",
|
|
f"path='{path}'\nold='''{old}'''\nnew='''{new}'''",
|
|
tool_name="edit"),
|
|
]
|
|
|
|
def test_owner_key_shape(self):
|
|
assert HyMemoryClient._chain_owner("u1", "repo1") == "u1|repo1"
|
|
# 两个都空 → "",保持旧的单用户单仓数据兼容
|
|
assert HyMemoryClient._chain_owner(None, None) == ""
|
|
assert HyMemoryClient._chain_owner("", "") == ""
|
|
|
|
def test_two_users_same_branch_do_not_leak(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
p = os.path.join(d, "ch")
|
|
HA = HyCodeMem(p, owner="userA|repo1")
|
|
HA.observe(HyMemoryClient._chain_text(
|
|
self._edit("On branch feat/q4, A 改的", "x = 1\n", "x = 2\n")),
|
|
meta={"branch": "feat/q4"})
|
|
# 同一个库文件、同一个分支、同一个文件名
|
|
HB = HyCodeMem(p, owner="userB|repo1")
|
|
HB.observe(HyMemoryClient._chain_text(
|
|
self._edit("On branch feat/q4, B 改的", "y = 1\n", "y = 2\n")),
|
|
meta={"branch": "feat/q4"})
|
|
|
|
blkA, _ = HA.decorate([dict(self._FACT[0])], instruction="q")
|
|
blkB, _ = HB.decorate([dict(self._FACT[0])], instruction="q")
|
|
assert "x = 2" in blkA and "y = 2" not in blkA, "A 读到了 B 的 diff"
|
|
assert "y = 2" in blkB and "x = 2" not in blkB, "B 读到了 A 的 diff"
|
|
|
|
def test_empty_owner_is_not_a_public_pool(self):
|
|
# 空 owner 只匹配空 owner,否则旧的空 owner 数据会错误读取所有新写入。
|
|
with tempfile.TemporaryDirectory() as d:
|
|
p = os.path.join(d, "ch")
|
|
H1 = HyCodeMem(p, owner="u1|r1")
|
|
H1.observe(HyMemoryClient._chain_text(
|
|
self._edit("On branch feat/q4, u1 改的", "x = 1\n", "x = 2\n")),
|
|
meta={"branch": "feat/q4"})
|
|
H0 = HyCodeMem(p) # owner=''
|
|
blk, used = H0.decorate([dict(self._FACT[0])], instruction="q")
|
|
assert blk == "" and used == []
|
|
|
|
def test_stats_scoped_to_owner_by_default(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
p = os.path.join(d, "ch")
|
|
for o, var in (("u1|r1", "a"), ("u2|r1", "b")):
|
|
H = HyCodeMem(p, owner=o)
|
|
H.observe(HyMemoryClient._chain_text(
|
|
self._edit(f"On branch feat/q4, {o}", f"{var} = 1\n", f"{var} = 2\n")),
|
|
meta={"branch": "feat/q4"})
|
|
H = HyCodeMem(p, owner="u1|r1")
|
|
# 默认只数自己的 —— 灌库自检靠这个数, 报全库会整体偏大
|
|
assert H.stats()["threads"] == 1
|
|
assert H.stats(all_owners=True)["threads"] == 2
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 8. 路径③ 的失败闸门
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestEditFailureGate:
|
|
"""从对话合成 diff 时, 失败的编辑不该进链。
|
|
|
|
链是"哪一版赢了"的唯一依据 —— 掺一个没生效的版本进去, agent 可能照着它写。
|
|
"""
|
|
|
|
_EDIT = ('path = "cfg.py"\n'
|
|
'old = """mult = 255.0"""\n'
|
|
'new = """mult = 360.0"""')
|
|
|
|
def _observe(self, tmpdir, tail, tag):
|
|
H = HyCodeMem(os.path.join(tmpdir, tag))
|
|
t = HyMemoryClient._chain_text([
|
|
_FakeMsg("user", "On branch feat/q4, 改 mult"),
|
|
_FakeMsg("tool", self._EDIT + "\n" + tail, tool_name="bash"),
|
|
])
|
|
return H.observe(t, meta={"branch": "feat/q4"})
|
|
|
|
@pytest.mark.parametrize("tail,tag", [
|
|
("Traceback (most recent call last):\n File \"<stdin>\"\nAssertionError", "as"),
|
|
("old not in s", "ni"),
|
|
("OBSERVATION: <returncode>2</returncode>", "rc2"),
|
|
("SyntaxError: unterminated string literal", "se"),
|
|
])
|
|
def test_failed_edits_blocked(self, tail, tag):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
assert self._observe(d, tail, tag) == 0
|
|
|
|
@pytest.mark.parametrize("tail,tag", [
|
|
("OBSERVATION: <returncode>0</returncode>", "rc0"),
|
|
("REPLACED=1", "rp"),
|
|
("", "none"), # 没有任何结果标记 → 不拦(宁可收, 别漏真改动)
|
|
])
|
|
def test_successful_and_unmarked_edits_kept(self, tail, tag):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
assert self._observe(d, tail, tag) == 1
|
|
|
|
def test_retry_after_error_is_kept(self):
|
|
"""★ 失败词之后又有成功迹象 → 重试成功了, 不能拦。
|
|
|
|
实测九仓 910 个路径③轮里有 9 轮命中失败词, 其中 **6 轮**后面紧跟
|
|
returncode>0 / REPLACED=1。只看失败词会把这 6 轮真改动误拦。
|
|
"""
|
|
with tempfile.TemporaryDirectory() as d:
|
|
assert self._observe(d, "AssertionError\n再试一次\nREPLACED=1", "rt") == 1
|
|
|
|
def test_failure_patterns_are_case_sensitive(self):
|
|
"""★ 判据不能用 re.I —— 会撞源码标识符。
|
|
|
|
我第一版用 re.I 扫 `FAILED|...`, 在九仓上报出 50 轮失败, 其实 50 个全是
|
|
ansible 源码里的函数名 `v2_runner_on_failed` 被匹配到, 而那些轮
|
|
<returncode>0</returncode> 明明是成功的。
|
|
"""
|
|
from hy_memory.coding.chain_store import _EDIT_FAIL
|
|
assert not _EDIT_FAIL.search("def v2_runner_on_failed(self, result):")
|
|
assert not _EDIT_FAIL.search(" self._handle_failed_task()")
|
|
# 真的异常类名要抓到
|
|
assert _EDIT_FAIL.search("\nAssertionError\n")
|