test(contract): 落成五个接缝的契约骨架,用它逼出七个设计洞
第 ④ 阶段的核心交付物。这套测试不针对任何具体实现,写的是「不管你怎么实现 都必须满足这些行为」,下游写完自己的实现接到 fixture 上跑一遍即可, 是任何新适配器的准入标准(CLAUDE.md §0)。 现在全部跳过,因为公共类型与 Protocol 还没落地。价值不在跑,在写: 写一条契约要求把每次调用逐字写出来——方法叫什么、参数填什么、返回值怎么取, 而散文里读着通顺的地方,落到这一步就露出来了。 三轮文档评审没报出的七个洞,写这套测试时全部撞了出来: 没有动作的步 result_id 填什么;三个 ActionStatus 取值的触发条件; 动作被拒绝时观察的来源(执行器与 SyntheticObservations 两处都有); 解释器能不能抛异常;Event 没有字段所以事件出口的契约只写得出一半; read_log 读不存在的运行必须返回空日志而不是抛异常; 以及原子性与前缀持久性这两条 0005 的承诺根本没有机器兜底—— 写这套测试之前我们默认它们会被契约测试接住。 洞标成 xfail(strict=True) 而不是常驻 fail:一个永远红的套件会训练所有人忽略红。 它们都不带 fixture,否则会被「实现还没有」那个跳过挡住, 于是「答不上来」就伪装成了「还没轮到」。补上之后 XPASS 会报错,逼人回来删标记。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
"""决策解释接缝的行为契约。
|
||||
|
||||
两个已知形态:一个从代码围栏里抽 Python 源码,一个从 JSON 里抽工具名与参数。库不带任何
|
||||
默认实现——带了就等于替某一家定了动作语言。
|
||||
|
||||
## 写这份文件时撞出来的、`design/0006` 还答不上的问题
|
||||
|
||||
模型输出完全无法解释时,解释器是返回「无效决策」还是抛异常。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_parse_is_synchronous(decision_parser, records):
|
||||
"""`parse` 是同步的,不是协程。
|
||||
|
||||
解释一次模型回复是纯计算,没有等待点。写成协程会让每个只想写测试替身的下游多套一层
|
||||
`async def`,也会诱导实现方在里面做 I/O——而这个接缝一旦做起 I/O,「恢复时重新解释
|
||||
被打断的那一步」就不再是安全操作了。
|
||||
"""
|
||||
parsed = decision_parser.parse(records.reply(content="anything"))
|
||||
|
||||
assert not hasattr(parsed, "__await__")
|
||||
|
||||
|
||||
def test_history_text_is_what_goes_back_into_the_conversation(decision_parser, records):
|
||||
"""`history_text` 是这一步回填进历史的那段文本,可以与模型原文不同。
|
||||
|
||||
解释器有权改写它:dissect 的解析器把第一个代码围栏之后的内容整段丢掉,因为模型常在
|
||||
代码块后面编造「执行结果」。库这边只有模型原文,照它回填,模型下一轮会看见自己编的
|
||||
那段,而迁移前它看不见。
|
||||
"""
|
||||
reply = records.reply(content="```python\nprint(1)\n```\nThe command succeeded.")
|
||||
parsed = decision_parser.parse(reply)
|
||||
|
||||
assert isinstance(parsed.history_text, str)
|
||||
assert len(parsed.history_text) <= len(reply.content)
|
||||
|
||||
|
||||
def test_invalid_decision_explanation_is_what_is_fed_back(decision_parser, records):
|
||||
"""无效决策的说明文本**就是**回喂给模型的那段观察,不是从一个固定串里取。
|
||||
|
||||
dissect 的解析器对五种解析失败各有一条对症说明(没有代码块、空的未闭合块、闭合围栏后
|
||||
跟了别的内容、多块策略下第一块为空、拼接策略下全空)。压成一句会改掉它的实验条件——
|
||||
模型收到的纠错信息变了,它的纠错行为也就变了。
|
||||
"""
|
||||
parsed = decision_parser.parse(records.reply(content="没有任何代码块"))
|
||||
|
||||
assert isinstance(parsed.decision.explanation, str)
|
||||
assert parsed.decision.explanation != ""
|
||||
|
||||
|
||||
def test_action_carries_its_trace_form(decision_parser, records):
|
||||
"""动作分支要带「这一步的动作在轨迹里长什么样」,由实现方决定内容,库原样填进步记录。
|
||||
|
||||
dissect 传那段 Python 源码,GovDoc 传序列化后的参数。没有这个字段,dissect 轨迹里那一列
|
||||
会被库改写,而那个文件是它的反思模型的唯一输入界面。
|
||||
"""
|
||||
parsed = decision_parser.parse(records.reply(content="```python\nprint(1)\n```"))
|
||||
|
||||
assert isinstance(parsed.decision.text, str)
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="design/0006 答不上,见 docstring", strict=True)
|
||||
def test_unparseable_output_returns_invalid_decision_rather_than_raising():
|
||||
"""模型输出完全无法解释时,解释器返回「无效决策」还是抛异常。
|
||||
|
||||
`design/0006` 定了三个分支——动作、最终回答、无效决策——但没说「解释器可以抛异常吗」。
|
||||
两条路后果完全不同:返回无效决策,那一步照常留痕、说明文本回喂给模型、循环继续;抛
|
||||
异常,库要么把它翻译成某个停止原因终止整次运行,要么让它穿出去炸掉调用方。
|
||||
|
||||
dissect 的解析器不抛异常,所以它撞不到这个分歧。但契约测试是**任何新适配器的准入
|
||||
标准**,一个会抛异常的实现照现在的契约既不算违规也不算合规。
|
||||
"""
|
||||
pytest.fail("解释器能不能抛异常、抛了怎么办,没有定义")
|
||||
Reference in New Issue
Block a user