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,101 @@
|
||||
"""动作执行接缝的行为契约。
|
||||
|
||||
两个已知形态差别很大:一个把一段代码交给已经开好的容器会话、状态恒为「已执行」,一个查
|
||||
工具注册表分发、工具不存在或参数不合法时返回「未执行」。下面每一条都要对两者同时成立。
|
||||
|
||||
## 写这份文件时撞出来的、`design/0006` 还答不上的问题
|
||||
|
||||
1. 三个状态取值分别在什么条件下被赋上,从来没有正面写过。
|
||||
2. 返回「未执行」时,那段观察是执行器给的还是库合成的——两处都有来源,没说以谁为准。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
async def test_returns_all_five_fields(action_executor, records):
|
||||
"""返回值必须带齐五个字段,一个都不能省。
|
||||
|
||||
库拿这五个字段填步记录里对应的五列。少一个,那一列就只能填默认值,而默认值与真实值在
|
||||
轨迹里长得一模一样——事后没有任何办法把「执行器没给」和「值确实是这个」分开。
|
||||
"""
|
||||
outcome = await action_executor.execute(records.action(text="noop"))
|
||||
|
||||
assert outcome.status is not None
|
||||
assert isinstance(outcome.observation, str)
|
||||
assert isinstance(outcome.observation_is_synthetic, bool)
|
||||
assert isinstance(outcome.env_reported_completion, bool)
|
||||
assert isinstance(outcome.observation_truncated_chars, int)
|
||||
|
||||
|
||||
async def test_completion_signal_is_a_plain_boolean(action_executor, records):
|
||||
"""完成信号是布尔,没有第三个取值,恒为「未完成」不是故障。
|
||||
|
||||
没有环境完成信号的环境就是这么返回的——GovDoc 全部、dissect 的两个非 AppWorld
|
||||
benchmark 都是。初稿把它定成「可为空表示取不到」并把空值判为环境故障,照那个写法
|
||||
GovDoc 的每一次运行都会在第一步撞环境故障终止。
|
||||
"""
|
||||
outcome = await action_executor.execute(records.action(text="noop"))
|
||||
|
||||
assert outcome.env_reported_completion in (True, False)
|
||||
|
||||
|
||||
async def test_action_error_is_a_normal_observation_not_an_env_error(action_executor, records):
|
||||
"""动作本身报错是正常观察,要原样回喂让模型自己纠正,不是环境故障。
|
||||
|
||||
代码抛异常、命令返回非零,都属于这一类。判成环境故障会让一次运行在模型本来能自我纠正
|
||||
的地方直接终止,而轨迹上看不出它本可以继续。只有环境自己坏了(连不上、协议不对)才
|
||||
另算。
|
||||
"""
|
||||
outcome = await action_executor.execute(records.action(text="raise RuntimeError()"))
|
||||
|
||||
assert outcome.status == records.action_status.EXECUTED
|
||||
assert outcome.observation != ""
|
||||
|
||||
|
||||
async def test_cancellation_propagates_and_is_not_swallowed(action_executor, records):
|
||||
"""取消要能穿过动作执行,`CancelledError` 不许被捕获吞没。
|
||||
|
||||
吞掉它的后果不是「取消失败」这么直白——是容器租约、连接和临时目录持续泄漏,而且一声
|
||||
不吭。这条是 `CLAUDE.md` §1.6,对每一个执行器实现都成立。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
task = asyncio.ensure_future(action_executor.execute(records.action(text="sleep")))
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="design/0006 答不上,见 docstring", strict=True)
|
||||
def test_status_values_have_defined_trigger_conditions():
|
||||
"""三个状态取值各自在什么条件下被赋上。
|
||||
|
||||
`design/0006` 只列了 `EXECUTED` / `NOT_EXECUTED` / `ENV_ERROR` 三个取值,没有正面写过
|
||||
触发条件。现在只能从 `SyntheticObservations` 那两个字段名反推——工具不存在或参数不合法
|
||||
大概是 `NOT_EXECUTED`,环境故障大概是 `ENV_ERROR`——而「大概」不能写成断言。
|
||||
|
||||
这条不定下来,两个下游会各自理解一套,而两套都不报错:dissect 的执行器状态恒为
|
||||
`EXECUTED`,它撞不到这个分歧;GovDoc 撞得到,但表现是停止原因的分布变了,不是异常。
|
||||
|
||||
还有一处连带的:`ActionStatus.ENV_ERROR` 与 `StopReason.ENV_ERROR` 同名不同类型,前者
|
||||
出现是不是必然导致后者,也没写。
|
||||
"""
|
||||
pytest.fail("三个 ActionStatus 取值的触发条件没有定义")
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="design/0006 答不上,见 docstring", strict=True)
|
||||
def test_who_supplies_the_observation_when_the_action_is_rejected():
|
||||
"""动作被拒绝时,那段观察是执行器给的还是库合成的。
|
||||
|
||||
两处都有来源:执行器的返回值里有 `observation` 字段,而定义上又挂着
|
||||
`SyntheticObservations.action_rejected`。`design/0006` 没说以谁为准。
|
||||
|
||||
这不是风格问题。如果以库为准,执行器填的那段就被丢掉,而它可能带着「哪个参数不合法」
|
||||
这种只有执行器知道的信息;如果以执行器为准,那 `SyntheticObservations` 那个字段永远
|
||||
用不上,它就是个死字段。而 `observation_is_synthetic` 该填什么,取决于这个答案。
|
||||
"""
|
||||
pytest.fail("动作被拒绝时观察的来源没有定义")
|
||||
Reference in New Issue
Block a user