1fac387e75
tests/ 不进 wheel,所以那套被 CLAUDE.md §0 称作「任何新适配器的准入标准」的用例,第一个 下游根本拿不到。**接法同时换掉**:pytest 的 conftest 只沿被收集文件的目录链查找,装在 site-packages 里的测试模块看不见下游的 conftest,原来那个「在自己的 conftest 里覆盖同名 fixture」的接法在发布之后走不通。改成继承契约基类,下游的子类定义在自己的目录链上。 **搬的过程中发现这套准入标准从来没被执行过。** test_model_client.py 有四条用例调用 records.model_call(...),而工厂里根本没有这个方法——它没炸是因为那个 fixture 默认 skip。 五个接缝里只有存储那套被真跑过(15 条跳过里有 15 条是这四套)。 所以这个提交的另一半是让它真的跑起来。存储接两个实现(一份契约同时验多个实现,正是换接法 换来的);动作执行接注册表分发器,外加一个有真实等待点的替身,否则那条取消用例的断言半边 永远走不到;模型调用接网关适配器,落在 integration,它连的是真网关;决策解释与事件出口各 接一个测试替身——替身住在 tests/ 里不进 wheel,下游拿不到,所以不违反「库不带默认实现」, 判据是下游拿不拿得到。 **一并清掉两类坏用例。** 五条函数体只有 docstring、一个断言都没有却报 PASSED 的假绿——一个 准入标准里出现假绿比出现跳过糟得多,下游看到全绿会以为验过了。以及一条端口从没承诺过的 长度断言(len(history_text) <= len(reply.content)):压测的 AppWorld 场景为了迁就它,刻意 不补被复刻的实现真的会补的三个反引号,注释里写着「补一个字符就违约」。七条「这一层验不了」 统一成无条件 skip,理由字符串写全「承诺是什么/为什么验不了/你该在哪儿自己验」。 **发一个 pytest11 entry point,只为换回断言重写。** 契约模块不在下游的 python_files 里, 默认不被重写,于是一条契约失败时下游看到的是光秃秃的 AssertionError。不做的话没有任何东西 会报错,纯静默退化。实测过:editable 安装下 entry point 注册了但重写不生效(RECORD 里没有 包文件),要装真 wheel 才验得出来。
196 lines
12 KiB
Python
196 lines
12 KiB
Python
"""动作执行接缝的行为契约。
|
||
|
||
两个已知形态差别很大:一个把一段代码交给已经开好的容器会话、状态恒为「已执行」,一个查
|
||
工具注册表分发、工具不存在或参数不合法时返回「未执行」。下面每一条都要对两者同时成立。
|
||
|
||
## 写这套用例时撞出来的两个问题,`design/0007` 决策一与决策二答了
|
||
|
||
三个状态取值各自在什么条件下被赋上、返回「未执行」时那段观察由谁给。两条的答案都落在
|
||
**库这一侧**,所以它们的断言不在这个接缝的契约里,见文末那两条说明。
|
||
|
||
## 实现方不返回结果、直接抛出时会怎样,`design/0016` 决策一答了
|
||
|
||
环境自己坏了走返回值,抛出来的异常库不接管。这一条同样验不到这一层,说明在文末第三条。
|
||
"""
|
||
|
||
import asyncio
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from polyloop.ports import ActionExecutor
|
||
from polyloop.testing._records import ContractBase
|
||
|
||
|
||
class ActionExecutorContract(ContractBase):
|
||
"""动作执行接缝的准入套件。下游继承它,覆盖 `action_executor` 与 `action_samples`。"""
|
||
|
||
@pytest.fixture
|
||
def action_executor(self) -> ActionExecutor:
|
||
"""被测的动作执行接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
|
||
raise NotImplementedError(
|
||
"在你的子类里覆盖 `action_executor` fixture,返回一个 ActionExecutor 实现的实例。"
|
||
)
|
||
|
||
@pytest.fixture
|
||
def action_samples(self) -> Any:
|
||
"""被测执行器认得的两个动作,由实现方提供。
|
||
|
||
**套件不许自己写死输入。** 动作语言是实现方定的:一段代码、一次工具调用、一条命令。
|
||
拿一段文本形式的代码去喂一个按工具名分发的执行器,它正确地返回「未执行」,而写死输入
|
||
的套件会把这个正确行为判成失败。
|
||
|
||
返回一个带两个属性的对象,两个属性都是 `polyloop.ports.Action`:
|
||
|
||
- `executes_cleanly`——跑得完,动作本身不报错。
|
||
- `executes_but_errors`——跑得完,但动作本身报错(代码抛异常、命令返回非零)。
|
||
|
||
**两个动作执行完的状态都该是 `ActionStatus.EXECUTED`。** 这正是契约要断言的:动作本身
|
||
报错是正常观察,不是环境故障。给一个会被判成「未执行」的动作,红的原因就与实现的对错
|
||
无关了。
|
||
|
||
**两个动作都不许触碰真实的外部副作用**——删文件、发请求、花钱。套件会真的执行它们,
|
||
而且会在执行到一半时取消其中一个,那时副作用做了多少是未知的。
|
||
|
||
套件会执行同一个动作不止一次,两次的观察不必逐字相同,但状态必须相同。
|
||
|
||
**退化情况:一个不存在「动作本身会报错」这种情形的执行器仍然要给出
|
||
`executes_but_errors`。** 比如一个在执行之前就把不合法的动作全挡掉的实现,它可以给一个
|
||
观察里带着错误信息、状态仍是「已执行」的动作。真的构造不出来,就在子类里重写用到它的
|
||
那条用例并写明理由。
|
||
"""
|
||
raise NotImplementedError(
|
||
"在你的子类里覆盖 `action_samples` fixture,返回一个带 `executes_cleanly` 与 "
|
||
"`executes_but_errors` 两个 Action 属性的对象。"
|
||
)
|
||
|
||
async def test_returns_all_five_fields(self, action_executor, action_samples):
|
||
"""返回值必须带齐五个字段,一个都不能省。
|
||
|
||
库拿这五个字段填步记录里对应的五列。少一个,那一列就只能填默认值,而默认值与真实值在
|
||
轨迹里长得一模一样——事后没有任何办法把「执行器没给」和「值确实是这个」分开。
|
||
"""
|
||
outcome = await action_executor.execute(action_samples.executes_cleanly)
|
||
|
||
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(self, action_executor, action_samples):
|
||
"""完成信号是布尔,没有第三个取值,恒为「未完成」不是故障。
|
||
|
||
没有环境完成信号的环境就是这么返回的——GovDoc 全部、dissect 的几个环境都是。初稿把它
|
||
定成「可为空表示取不到」并把空值判为环境故障,照那个写法 GovDoc 的每一次运行都会在
|
||
第一步撞环境故障终止。
|
||
"""
|
||
outcome = await action_executor.execute(action_samples.executes_cleanly)
|
||
|
||
assert outcome.env_reported_completion in (True, False)
|
||
|
||
async def test_action_error_is_a_normal_observation_not_an_env_error(
|
||
self, action_executor, action_samples, records
|
||
):
|
||
"""动作本身报错是正常观察,要原样回喂让模型自己纠正,不是环境故障。
|
||
|
||
代码抛异常、命令返回非零,都属于这一类。判成环境故障会让一次运行在模型本来能自我纠正
|
||
的地方直接终止,而轨迹上看不出它本可以继续。只有环境自己坏了(连不上、协议不对)才
|
||
另算。
|
||
"""
|
||
outcome = await action_executor.execute(action_samples.executes_but_errors)
|
||
|
||
assert outcome.status == records.action_status.EXECUTED
|
||
assert outcome.observation != ""
|
||
|
||
async def test_cancellation_propagates_and_is_not_swallowed(
|
||
self, action_executor, action_samples
|
||
):
|
||
"""取消要能穿过动作执行,`CancelledError` 不许被捕获吞没。
|
||
|
||
吞掉它的后果不是「取消失败」这么直白——是容器租约、连接和临时目录持续泄漏,而且一声
|
||
不吭。这条是 `CLAUDE.md` §1.6,对每一个执行器实现都成立。
|
||
|
||
**`cancel()` 返回 `False` 是能力判定,不是失败。** 一个在单个事件循环 tick 之内就返回
|
||
的执行器,取消发出去时它已经跑完,没有任何机会吞掉取消——这条契约对它无从谈起。把它
|
||
判成不合格会让这条用例的成败取决于实现跑得多快,而一个偶发变红的准入标准会训练下游
|
||
忽略红,那比少验一条糟。
|
||
"""
|
||
task = asyncio.ensure_future(action_executor.execute(action_samples.executes_cleanly))
|
||
await asyncio.sleep(0)
|
||
if not task.cancel():
|
||
pytest.skip(
|
||
"承诺:取消要能穿过动作执行,CancelledError 不许被捕获吞没。"
|
||
"这一次验不了——被测实现在一个事件循环 tick 之内就返回了,取消发出去时它已经跑完,"
|
||
"根本没有机会吞掉取消。跑得太快不是违约,这条契约对它无从谈起,不必去改实现。"
|
||
"自己验:只有在实现真的有等待点(网络往返、子进程、容器会话)时这条才验得出来。"
|
||
)
|
||
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await task
|
||
|
||
def test_status_trigger_conditions_are_asserted_against_the_library_not_here(self):
|
||
"""三个状态的触发条件(`design/0007` 决策一)验不到这一层,原因在这里。
|
||
|
||
触发条件是**执行器自己的判断**:动作真的跑过了记 `EXECUTED`(哪怕它报错),没进执行
|
||
记 `NOT_EXECUTED`,环境自己坏了记 `ENV_ERROR`。这套件面对的是一个任意实现,没有办法
|
||
逼它进入后两档——拿一个「几乎不可能存在的工具名」去探,会把 dissect 那种动作语言里
|
||
根本没有工具名、状态恒为 `EXECUTED` 的合法实现判成不合格。
|
||
|
||
库这一侧的连带后果是能验的,也验了:`ENV_ERROR` 必然导致 `StopReason.ENV_ERROR`、
|
||
`NOT_EXECUTED` 不终止运行,两条由库自己的测试守着,不在这个接缝的契约里。
|
||
|
||
这条留成一条跳过,是为了让下一个想在这儿补断言的人先看到上面那段。
|
||
"""
|
||
pytest.skip(
|
||
"承诺:动作跑过了记 EXECUTED(哪怕它报错),没进执行记 NOT_EXECUTED,"
|
||
"环境自己坏了记 ENV_ERROR。"
|
||
"这一层验不了——套件面对的是一个任意实现,没有办法逼它进入后两档,"
|
||
"而拿一个不存在的工具名去探,会把状态恒为 EXECUTED 的合法实现判成不合格。"
|
||
"自己验:在你自己实现的测试里,用你那套动作语言各造一个走到后两档的动作。"
|
||
"后两档在库这一侧的连带后果由库自己的测试守着。"
|
||
)
|
||
|
||
def test_failure_is_expressed_as_a_return_value_asserted_in_the_library_not_here(self):
|
||
"""环境故障走返回值、抛出的异常库不接管(`design/0016` 决策一),验不到这一层。
|
||
|
||
环境自己坏了——连不上、协议不对、开好的会话没了——执行器返回 `ActionStatus.ENV_ERROR`,
|
||
不要以异常表达。实现方真的抛了异常,库不捕获,异常原样穿出 `run()` 与 `resume()`:调用方
|
||
拿到的是那个异常本身,不是一个正常返回的运行结果。
|
||
|
||
这套件面对的是一个任意实现,没有办法逼它进入环境故障那一档——真去把它的网络掐掉既不
|
||
可移植,也会把那些根本没有网络的合法实现判成不合格。这和三个状态的触发条件验不到那一层
|
||
是同一个原因。
|
||
|
||
`asyncio.CancelledError` 是那个必须穿过去的异常,它验得到,断言在取消那条用例里。库为什么
|
||
不像模型调用接缝那样把这个异常接住,见 `research-wiki/design/0016-action-executor-failure.md`。
|
||
|
||
这条留成一条跳过,是为了让下一个想在这儿补断言的人先看到上面那段。
|
||
"""
|
||
pytest.skip(
|
||
"承诺:环境自己坏了走 ActionStatus.ENV_ERROR 返回值,不要以异常表达;"
|
||
"实现方真的抛了异常,库不捕获,异常原样穿出 run() 与 resume()。"
|
||
"这一层验不了——套件面对的是一个任意实现,没有办法逼它进入环境故障那一档,"
|
||
"而真去把它的网络掐掉既不可移植,也会把根本没有网络的合法实现判成不合格。"
|
||
"自己验:在你自己实现的测试里,用你那套动作语言造一个真的走到环境故障的动作。"
|
||
"抛出的异常穿出 run()、日志停在「动作意图有、结果无」、续跑判成状态未知,"
|
||
"这些连带后果由库自己的测试守着。"
|
||
)
|
||
|
||
def test_the_observation_substitution_is_asserted_in_the_library_not_here(self):
|
||
"""动作被拒绝时那段观察由库合成(`design/0007` 决策二),而判定发生在库这一侧。
|
||
|
||
执行器照常填自己的 `observation`——它不该知道库会不会采用,也不必知道:那段文本仍然
|
||
随「一步走完」记录原样落盘,被拒绝那一档下它是日志里唯一的拒绝说明
|
||
(`design/0013` 决策六)。库只是不让它进历史,因为模型看得见的东西必须能进参数快照。
|
||
|
||
「库替换了它」是整次运行的行为,不在这个接缝的契约里。
|
||
"""
|
||
pytest.skip(
|
||
"承诺:动作被拒绝时进历史的那段观察由库合成,执行器填的那段仍原样落盘。"
|
||
"这一层验不了——替换发生在库这一侧,要跑完一次完整运行再读日志才看得见,"
|
||
"而这个接缝只看得见一个执行器实现。"
|
||
"自己验:不用验。执行器照常填自己的 observation 就是合规的,"
|
||
"「库替换了它」由库自己的测试守着。"
|
||
)
|