8c642e7881
它们函数体是空的,名字却承诺了一个行为,读起来像「这条已经验过了」——而契约套件是新适配器 的准入标准,下游跑一遍看见绿的会以为自己那一条被验过。名字改成说清楚断言在哪一层, 函数体那段解释保持不动。 Codex 另报「测试 docstring 里出现 GovDoc 是业务词汇」,不采纳:§1.1 那道扫描挡的是领域词 (公文、超声、招标),说明一条测试被哪个消费者的需求逼出来不构成业务假设,仓库里这类引用 本来就有十几处。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 lines
4.8 KiB
Python
98 lines
4.8 KiB
Python
"""动作执行接缝的行为契约。
|
||
|
||
两个已知形态差别很大:一个把一段代码交给已经开好的容器会话、状态恒为「已执行」,一个查
|
||
工具注册表分发、工具不存在或参数不合法时返回「未执行」。下面每一条都要对两者同时成立。
|
||
|
||
## 写这份文件时撞出来的两个问题,`design/0007` 决策一与决策二答了
|
||
|
||
三个状态取值各自在什么条件下被赋上、返回「未执行」时那段观察由谁给。两条的答案都落在
|
||
**库这一侧**,所以它们的断言不在这份文件里,见文末那两条说明。
|
||
"""
|
||
|
||
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
|
||
|
||
|
||
def test_status_trigger_conditions_are_asserted_against_the_library_not_here():
|
||
"""三个状态的触发条件(`design/0007` 决策一)验不到这一层,原因在这里。
|
||
|
||
触发条件是**执行器自己的判断**:动作真的跑过了记 `EXECUTED`(哪怕它报错),没进执行
|
||
记 `NOT_EXECUTED`,环境自己坏了记 `ENV_ERROR`。这套件面对的是一个任意实现,没有办法
|
||
逼它进入后两档——拿一个「几乎不可能存在的工具名」去探,会把 dissect 那种动作语言里
|
||
根本没有工具名、状态恒为 `EXECUTED` 的合法实现判成不合格。
|
||
|
||
库这一侧的连带后果是能验的,也验了:`ENV_ERROR` 必然导致 `StopReason.ENV_ERROR`、
|
||
`NOT_EXECUTED` 不终止运行,两条在 `tests/unit/test_session.py` 里。
|
||
|
||
这条留成一个不断言的说明,是为了让下一个想在这儿补断言的人先看到上面那段。
|
||
"""
|
||
|
||
|
||
def test_the_observation_substitution_is_asserted_in_the_library_not_here():
|
||
"""动作被拒绝时那段观察由库合成(`design/0007` 决策二),而判定发生在库这一侧。
|
||
|
||
执行器照常填自己的 `observation`——它不该知道库会不会采用,也不必知道:那段文本仍然
|
||
随「一步走完」记录原样落盘,被拒绝那一档下它是日志里唯一的拒绝说明
|
||
(`design/0013` 决策六)。库只是不让它进历史,因为模型看得见的东西必须能进参数快照。
|
||
|
||
「库替换了它」是整次运行的行为,断言在 `tests/unit/test_session.py`,不在这个接缝的
|
||
契约里。
|
||
"""
|