feat(testing): 契约套件搬进 polyloop.testing 随包发布,五套全部接上实现

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 才验得出来。
This commit is contained in:
2026-08-27 03:59:16 -04:00
parent 3492a2994a
commit 1fac387e75
22 changed files with 1538 additions and 632 deletions
+38 -3
View File
@@ -21,6 +21,11 @@ dependencies = []
# 模型适配器单独成一个 extra:不用它的人不该被迫装上网关,也不该在 import 时把网关连同 # 模型适配器单独成一个 extra:不用它的人不该被迫装上网关,也不该在 import 时把网关连同
# 它的 provider 目录一起拉起来(依赖规则 9)。PolyGateway 不在公共 PyPI 上,装它要配私有源。 # 它的 provider 目录一起拉起来(依赖规则 9)。PolyGateway 不在公共 PyPI 上,装它要配私有源。
gateway = ["polygateway>=1.1,<2"] gateway = ["polygateway>=1.1,<2"]
# 契约套件(polyloop.testing)跑起来要的两个包。**版本只写下界,不钉死**——这和下面 dev 那组
# 正好相反,理由也正好相反:dev 是本仓库自己的工具链,钉死是为了本地和 CI 跑的是同一套;
# testing 装进的是下游自己的环境,钉死会和下游已经在用的 pytest 打架,而下游没有第二个
# 虚拟环境可以放我们钉的那一版。下次想「顺手统一一下」这两组的写法时,先看这段。
testing = ["pytest>=8", "pytest-asyncio>=0.24"]
# dev 工具链的版本必须钉死,不用下界。教训来自 CHSAnalyzer:本地 conda 是 ruff 0.15.1、 # dev 工具链的版本必须钉死,不用下界。教训来自 CHSAnalyzer:本地 conda 是 ruff 0.15.1、
# CI 装到刚发布的 0.16.0,新版本多了几条规则,于是「本地全绿、CI 全红」。不钉的话 CI 会在 # CI 装到刚发布的 0.16.0,新版本多了几条规则,于是「本地全绿、CI 全红」。不钉的话 CI 会在
# 没有任何人改代码的情况下随上游发版随机变红,而随机的红叉很快就会训练所有人忽略红叉。 # 没有任何人改代码的情况下随上游发版随机变红,而随机的红叉很快就会训练所有人忽略红叉。
@@ -43,6 +48,13 @@ Homepage = "https://gitea.iomgaa.online/iomgaa/PolyLoop"
Changelog = "https://gitea.iomgaa.online/iomgaa/PolyLoop/src/branch/main/CHANGELOG.md" Changelog = "https://gitea.iomgaa.online/iomgaa/PolyLoop/src/branch/main/CHANGELOG.md"
Issues = "https://gitea.iomgaa.online/iomgaa/PolyLoop/issues" Issues = "https://gitea.iomgaa.online/iomgaa/PolyLoop/issues"
# pytest 插件入口。它换回来的是**断言重写**:契约模块不在下游的 `python_files` 匹配范围里,
# 默认不被重写,于是一条契约用例失败时下游只看到光秃秃的 AssertionError,没有等号两边的值。
# 声明了 pytest11 入口的发行包,它的每个 .py 文件都会被 pytest 标记为可重写。
# 不做的话没有任何东西会报错——纯静默退化,理由与边界写在 polyloop/testing/_plugin.py 里。
[project.entry-points.pytest11]
polyloop = "polyloop.testing._plugin"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
@@ -94,13 +106,14 @@ markers = [
addopts = "--strict-markers --import-mode=importlib -m 'not e2e'" addopts = "--strict-markers --import-mode=importlib -m 'not e2e'"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 依赖规则的机器形式。条规则本身与它们各自的理由在 # 依赖规则的机器形式。条规则本身与它们各自的理由在
# research-wiki/design/0003-public-api-shape.md 决策八,当前形状在 # research-wiki/design/0003-public-api-shape.md 决策八,当前形状在
# research-wiki/explanation/architecture.md 第七节。这里只写契约,不复述理由。 # research-wiki/explanation/architecture.md 第七节。这里只写契约,不复述理由。
# #
# 条里有两条写不成 import-linter 契约,它们是 tests/ 里的测试: # 条里有两条写不成 import-linter 契约,还有一条只有前半写得成;写不成的那些是 tests/ 里的测试:
# 规则 6types 与 ports 禁止 import 任何第三方包)——「任何第三方」不是一份可枚举的清单。 # 规则 6types 与 ports 禁止 import 任何第三方包)——「任何第三方」不是一份可枚举的清单。
# 规则 9import polyloop 之后 sys.modules 里没有 polygateway)——那是运行时事实,不是静态图。 # 规则 9import polyloop 之后 sys.modules 里没有 polygateway)——那是运行时事实,不是静态图。
# 规则 10 的后半(import polyloop 之后 sys.modules 里没有 pytest)——同上。前半在下面。
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
[tool.importlinter] [tool.importlinter]
root_packages = ["polyloop"] root_packages = ["polyloop"]
@@ -115,7 +128,7 @@ include_external_packages = true
name = "分层:装配层 > 逻辑层 > ports > types,且同层互不 import" name = "分层:装配层 > 逻辑层 > ports > types,且同层互不 import"
type = "layers" type = "layers"
layers = [ layers = [
"polyloop.session | polyloop.stores | polyloop.adapters", "polyloop.session | polyloop.stores | polyloop.adapters | polyloop.testing",
"polyloop.tools | polyloop._assembly | polyloop._stopping | polyloop._recovery | polyloop.serialization", "polyloop.tools | polyloop._assembly | polyloop._stopping | polyloop._recovery | polyloop.serialization",
"polyloop.ports", "polyloop.ports",
"polyloop.types", "polyloop.types",
@@ -131,6 +144,7 @@ forbidden_modules = [
"polyloop.session", "polyloop.session",
"polyloop.stores", "polyloop.stores",
"polyloop.adapters", "polyloop.adapters",
"polyloop.testing",
"polyloop.tools", "polyloop.tools",
"polyloop._assembly", "polyloop._assembly",
"polyloop._stopping", "polyloop._stopping",
@@ -153,6 +167,7 @@ type = "forbidden"
source_modules = [ source_modules = [
"polyloop.session", "polyloop.session",
"polyloop.stores", "polyloop.stores",
"polyloop.testing",
"polyloop.tools", "polyloop.tools",
"polyloop._assembly", "polyloop._assembly",
"polyloop._stopping", "polyloop._stopping",
@@ -163,6 +178,26 @@ source_modules = [
] ]
forbidden_modules = ["polygateway"] forbidden_modules = ["polygateway"]
# 规则 10 的前半。契约套件是 polyloop 里唯一允许碰 pytest 的地方,形状照着上面那条 polygateway 写。
# 别处 import pytest 的后果是每个装了本库的下游都被迫装上 pytest 才 import 得动 polyloop
# 而 pytest 只在 testing 这个 extra 里,核心的 dependencies 是空的。
[[tool.importlinter.contracts]]
name = "除 testing 外一切禁止 import pytest"
type = "forbidden"
source_modules = [
"polyloop.session",
"polyloop.stores",
"polyloop.adapters",
"polyloop.tools",
"polyloop._assembly",
"polyloop._stopping",
"polyloop._recovery",
"polyloop.serialization",
"polyloop.ports",
"polyloop.types",
]
forbidden_modules = ["pytest"]
# 规则 7。这一条是烟雾报警,不是纯度契约——os、subprocess、sqlite3 都能绕过它, # 规则 7。这一条是烟雾报警,不是纯度契约——os、subprocess、sqlite3 都能绕过它,
# 而 open() 是内置函数,import-linter 根本看不见。它拦得住最常见的那种偷懒 # 而 open() 是内置函数,import-linter 根本看不见。它拦得住最常见的那种偷懒
# (写着写着顺手 await 一下存储、顺手读个文件),拦不住存心的。 # (写着写着顺手 await 一下存储、顺手读个文件),拦不住存心的。
+81
View File
@@ -0,0 +1,81 @@
"""五个接缝的契约套件,随包发布,给下游当准入标准用。
每个接缝一个基类。这套用例不针对任何具体实现,写的是「不管你怎么实现,都必须满足这些
行为」——所以它自己不造实现,只声明「你得提供什么」。写完自己的存储或适配器之后,在自己的
测试文件里继承对应的基类、覆盖那几个必需 fixture,跑一遍全绿就算合格。
# tests/test_my_store.py
import pytest
from polyloop.testing import RunStoreContract
from myproject.storage import MyPostgresStore
class TestMyPostgresStore(RunStoreContract):
@pytest.fixture
def store(self, pg_pool):
return MyPostgresStore(pg_pool)
子类的类名要以 `Test` 开头,pytest 才收集它。基类自己叫 `...Contract` 正是为了不被收集:
pytest 按 `Test` 前缀匹配测试类,一个叫 `RunStoreContract` 的基类不会被当成测试类跑一遍,
于是它那些 `raise NotImplementedError` 的默认 fixture 也就不会失败。
**装它**`pip install "polyloop[testing]"`。
**必需的 fixture 在基类里都有一个默认实现,函数体是 `raise NotImplementedError`**,报错消息
写着该覆盖什么、该返回什么。忘了覆盖时看到的是这条消息,而不是 pytest 那句
`fixture 'store' not found` 加一整屏 available fixtures 列表——后者指向的是 site-packages 里
的库文件,第一反应会是库坏了。
**样本输入由实现方提供**,见 `DecisionParserContract.reply_samples`、
`ActionExecutorContract.action_samples` 与 `ModelClientContract.failing_call` 的 docstring。
套件不认识任何一家的动作语言,也不知道一家实现要怎样才会失败:拿一家的代码围栏去喂另一家的
JSON 解析器,后者正确地返回「无效决策」,而写死输入的套件会把这个正确行为判成失败。
**实现之间的能力差异走运行期跳过,不走失败。** 取消那两条就是这么处理的——一个在单个事件
循环 tick 之内就返回的实现根本没有机会吞掉取消,那条契约对它无从谈起,跳过的理由字符串会
说清楚这一点。看到这种跳过不必去改自己的实现。
**async 用例的事件循环归下游管。** 套件里的用例照常写成 `async def`,套件不做任何事件循环
安排。所以要么把 `asyncio_mode` 设成 `"auto"``pyproject.toml` 的
`[tool.pytest.ini_options]` 里),要么自己给子类打上对应的标记。没配对的失败是响亮的:
pytest 会明说 async def 函数不被原生支持,那条信息直接指向解法。
库不替下游安排循环,是因为下游的 fixture 很可能是异步的——一个数据库存储实现的连接池就是。
那个 fixture 在下游的循环里创建,库自己开的循环是另一个,跨循环使用 asyncio 对象会炸,而且
炸得很难查。
**`PYTEST_DISABLE_PLUGIN_AUTOLOAD=1` 的环境要多写一行。** 本包声明了一个 pytest 插件入口,
它唯一的作用是让 pytest 重写这些模块里的 `assert`,失败时打印出等号两边的实际值。那个环境
变量一设上,入口就不加载了,契约失败会退化成光秃秃的 `AssertionError`。这时在自己的
`conftest.py` 顶上补一行:
pytest.register_assert_rewrite("polyloop.testing")
**套件里一个自定义 marker 都不用。** 开了 `--strict-markers` 而没注册那个 marker 的话,炸掉
的是整个测试文件的收集,不是一条失败,而报错指向的同样是库的文件。要给这些用例分组就在自己
的子类文件上打标记,那个文件和那个 marker 都在你自己的仓库里。
**这里的每个名字都是公共承诺**:基类名、基类上的 fixture 名、每一条用例的方法名。下游的子类
按它们写,改名的代价和改公共类型的字段一样。用例方法名尤其要紧——下游要豁免某一条时按名字
重绑它,改名之后那个豁免会悄悄失效,跑出来照样全绿。
设计与取舍见 `research-wiki/design/0014-contract-suite-distribution.md`。
"""
from polyloop.testing._action_executor import ActionExecutorContract
from polyloop.testing._decision_parser import DecisionParserContract
from polyloop.testing._event_sink import EventSinkContract
from polyloop.testing._model_client import ModelClientContract
from polyloop.testing._records import RecordFactory
from polyloop.testing._run_store import RunStoreContract
__all__ = [
"ActionExecutorContract",
"DecisionParserContract",
"EventSinkContract",
"ModelClientContract",
"RecordFactory",
"RunStoreContract",
]
+195
View File
@@ -0,0 +1,195 @@
"""动作执行接缝的行为契约。
两个已知形态差别很大:一个把一段代码交给已经开好的容器会话、状态恒为「已执行」,一个查
工具注册表分发、工具不存在或参数不合法时返回「未执行」。下面每一条都要对两者同时成立。
## 写这套用例时撞出来的两个问题,`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 就是合规的,"
"「库替换了它」由库自己的测试守着。"
)
+130
View File
@@ -0,0 +1,130 @@
"""决策解释接缝的行为契约。
两个已知形态:一个从代码围栏里抽 Python 源码,一个从 JSON 里抽工具名与参数。库不带任何
默认实现——带了就等于替某一家定了动作语言。
## 写这套用例时撞出来的那个问题,`design/0007` 决策三答了
模型输出完全无法解释时,解释器返回「无效决策」,不抛异常。下面最后一条断言它。
"""
from typing import Any
import pytest
from polyloop.ports import DecisionParser, InvalidDecision
from polyloop.testing._records import ContractBase
class DecisionParserContract(ContractBase):
"""决策解释接缝的准入套件。下游继承它,覆盖 `decision_parser` 与 `reply_samples`。"""
@pytest.fixture
def decision_parser(self) -> DecisionParser:
"""被测的决策解释接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `decision_parser` fixture,返回一个 DecisionParser 实现的实例。"
)
@pytest.fixture
def reply_samples(self) -> Any:
"""被测解释器认得的两段模型回复,由实现方提供。
**套件不许自己写死输入。** 库不带默认解释器实现,也就不认识任何一家的动作语言:拿一家的
代码围栏去喂另一家的 JSON 解析器,后者正确地返回「无效决策」,而写死输入的套件会把这个
正确行为判成失败。这不是给套件开后门——「我这套语言里什么算合法动作」本来就只有实现方
答得出,套件断言的是**拿到之后的形状**,不是输入长什么样。
返回一个带两个属性的对象,两个属性都是 `polyloop.types.ModelReply`
- `yields_an_action`——喂给被测解释器会走到动作那一支的回复。
- `yields_invalid`——喂给被测解释器会走到「无效决策」那一支的回复。
套件会对同一个样本调用 `parse` 不止一次,两次的结果必须一样:一个靠内部计数器换答案
的解释器会让用例的成败取决于它们的执行顺序,而 pytest 的执行顺序不是承诺。
**`yields_an_action` 的解释过程不许有外部副作用**——套件只是解释它,不会执行解释出来
的动作,但一个在 `parse` 里就发请求的实现会在跑准入时真的发出去。
**退化情况:一个「什么都解释得出来」的实现仍然要给出 `yields_invalid`。** 找不到就
说明这个实现把无效决策那一支变成了死代码,而契约要求那一支存在——模型输出不合格式是
每天都在发生的事,那一支迟早会被走到。真的构造不出来,就在子类里重写用到它的那两条
用例并写明理由;不要拿一个其实解释得出动作的回复来充数,那样套件照样绿,而绿的含义
从「这一支对」变成了「这一支没验」。
"""
raise NotImplementedError(
"在你的子类里覆盖 `reply_samples` fixture,返回一个带 `yields_an_action` 与 "
"`yields_invalid` 两个 ModelReply 属性的对象。"
)
def test_parse_is_synchronous(self, decision_parser, reply_samples):
"""`parse` 是同步的,不是协程。
解释一次模型回复是纯计算,没有等待点。写成协程会让每个只想写测试替身的下游多套一层
`async def`,也会诱导实现方在里面做 I/O——而这个接缝一旦做起 I/O,「恢复时重新解释
被打断的那一步」就不再是安全操作了。
"""
parsed = decision_parser.parse(reply_samples.yields_an_action)
assert not hasattr(parsed, "__await__")
def test_history_text_is_what_goes_back_into_the_conversation(
self, decision_parser, reply_samples
):
"""`history_text` 是这一步回填进历史的那段文本,可以与模型原文不同。
解释器有权改写它:dissect 的解析器把第一个代码围栏之后的内容整段丢掉,因为模型常在
代码块后面编造「执行结果」。库这边只有模型原文,照它回填,模型下一轮会看见自己编的
那段,而迁移前它看不见。
**不断言它不长于模型原文。** 端口承诺的只有「可以改写」,而改写既可能是截短也可能是
补写:把一次 JSON 工具调用规范化成一段人读得懂的 assistant 文本、给被 stop 序列截断的
代码围栏补回结尾的三反引号,两种都没有违反端口的任何一条,长度却都会超过原文。断言
长度就是在准入标准里凭空多加一条端口没写过的约束,那些实现会在这里变红,而它们没有
任何地方需要改。
**输入由被测实现自己提供**,不由套件写死。库不带默认实现,也就不认识任何一家的动作
语言——拿 dissect 的代码围栏去喂 GovDoc 的 JSON 解析器,它正确地返回「无效决策」,
而套件会把这个正确行为判成失败。
"""
parsed = decision_parser.parse(reply_samples.yields_an_action)
assert isinstance(parsed.history_text, str)
def test_invalid_decision_explanation_is_what_is_fed_back(self, decision_parser, reply_samples):
"""无效决策的说明文本**就是**回喂给模型的那段观察,不是从一个固定串里取。
dissect 的解析器对五种解析失败各有一条对症说明(没有代码块、空的未闭合块、闭合围栏后
跟了别的内容、多块策略下第一块为空、拼接策略下全空)。压成一句会改掉它的实验条件——
模型收到的纠错信息变了,它的纠错行为也就变了。
"""
parsed = decision_parser.parse(reply_samples.yields_invalid)
assert isinstance(parsed.decision.explanation, str)
assert parsed.decision.explanation != ""
def test_action_carries_its_trace_form(self, decision_parser, reply_samples):
"""动作分支要带「这一步的动作在轨迹里长什么样」,由实现方决定内容,库原样填进步记录。
dissect 传那段 Python 源码,GovDoc 传序列化后的参数。没有这个字段,dissect 轨迹里那一列
会被库改写,而那个文件是它的反思模型的唯一输入界面。
"""
parsed = decision_parser.parse(reply_samples.yields_an_action)
assert isinstance(parsed.decision.text, str)
def test_unparseable_output_returns_invalid_decision_rather_than_raising(
self, decision_parser, reply_samples
):
"""模型输出完全无法解释时返回「无效决策」,不抛异常(`design/0007` 决策三)。
两条路后果完全不同:返回无效决策,那一步照常留痕、说明文本回喂给模型、循环继续;抛
异常,库要么把它翻译成某个停止原因终止整次运行,要么让它穿出去炸掉调用方。
dissect 的解析器不抛异常,所以它撞不到这个分歧。但契约测试是**任何新适配器的准入
标准**,所以这条要正面断言,不能靠「反正没人这么写」。
"""
parsed = decision_parser.parse(reply_samples.yields_invalid)
assert isinstance(parsed.decision, InvalidDecision)
assert parsed.decision.explanation != ""
+86
View File
@@ -0,0 +1,86 @@
"""事件出口的行为契约。
两个已知形态差别在可靠性要求上:一个把进度逐步回写业务数据库供前端轮询(要求低延迟、
可以丢),一个把审计事件送进日志管道(要求不丢、可以慢)。
## 写这套用例时撞出来的问题,`design/0013` 答了
「发出去的事件里有什么」当时验不了,因为 `Event` 只有一个名字没有字段。现在事件集定下来了,
而答案把这里的两条用例都挪走了——它们要断言的行为都在库那一侧,不在出口这一侧,见文末
那两条说明。
"""
import pytest
from polyloop.ports import EventSink
from polyloop.testing._records import ContractBase
class EventSinkContract(ContractBase):
"""事件出口的准入套件。下游继承它,覆盖 `event_sink`。"""
@pytest.fixture
def event_sink(self) -> EventSink:
"""被测的事件出口实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `event_sink` fixture,返回一个 EventSink 实现的实例。"
)
async def test_emit_accepts_an_event(self, event_sink, records):
"""能收下一个事件,正常路径不抛异常。"""
await event_sink.emit(records.event())
def test_a_raising_sink_is_compliant_so_this_layer_asserts_nothing(self):
"""**这一层不断言「emit 不抛」——一个后端连不上时抛异常的出口是合规实现。**
契约写的是「投递失败由**库**捕获、记日志、把失败计数加一,然后继续跑」,所以要断言的
行为在库那一侧,不在出口这一侧。原来这里写了一条 `await emit(...)` 不抛的断言,那会把
一个完全合法的审计 sink 判失败——它在日志管道不可用时抛 `ConnectionError`,而库本来就
该接住。
「库接住了失败并继续跑」属于整次运行的行为,不在这个接缝的契约里。这条留成一条跳过,
是为了让下一个想在这儿加断言的人先看到这段。
"""
pytest.skip(
"承诺:投递失败由库捕获、记日志、计数加一,然后继续跑,所以一个会抛异常的出口"
"是合规实现。"
"这一层验不了——「库接住了失败还在跑」要跑完一次完整运行才看得见,"
"而这个接缝只看得见一个出口实现。"
"自己验:不用验,也不要在这里补一条「emit 不抛」的断言——那会把一个在后端不可用时"
"抛异常的合法出口判成不合格。"
)
def test_the_no_re_emission_guarantee_is_asserted_in_the_library_not_here(self):
"""投递失败不再转成一条事件从同一个出口发出去(`design/0013` 决策七)。
那会自我喂食:一个持续失败的出口会让失败处理路径变成递归,而递归的表现是进程卡住或
栈溢出,不是一条错误日志。
**要断言的是库有没有再发一次,那是整次运行的行为**,所以断言由库自己的测试守着——
那边用一个恒抛异常的出口跑完一次运行,验出口收到的条数恰好等于步数。这个接缝自己
看不到「库发了几次」。
"""
pytest.skip(
"承诺:投递失败不会被转成一条事件从同一个出口再发一次。"
"这一层验不了——「库发了几次」是整次运行的行为,一个出口实现自己数不出来。"
"自己验:不用验,这条约束的是库不是出口;库那边用一个恒抛异常的出口跑完一次运行,"
"验出口收到的条数恰好等于步数。"
)
def test_the_audit_trail_is_asserted_against_the_log_not_here(self):
"""审计纪律由存储承担,不由事件流承担(`design/0013` 决策二)。
GovDoc 有一条硬纪律:agent 的原始输出、修复后的输出、恢复来源全程留痕,禁止静默修复。
这条测试原来断言「事件要同时带原文与修复后的文本」,而那个前提是错的——事件流可丢,
一件只存在于可丢通道里的事实撑不起「禁止静默修复」。
两份文本在意图日志里各有位置:原文在模型调用结果记录的回复里,修复后的那份是步记录的
`raw_output`。断言落在库那边,因为要跑完一次完整运行再把日志读回来,而这个接缝的契约
只看得见一个出口实现。
"""
pytest.skip(
"承诺:原始输出与修复后的输出全程留痕,而承担它的是意图日志,不是事件流——"
"事件流可丢,一件只存在于可丢通道里的事实撑不起「禁止静默修复」。"
"这一层验不了——要跑完一次完整运行再把日志读回来,而这个接缝只看得见一个出口实现。"
"自己验:靠存储接缝那套契约(RunStoreContract),不要指望事件里带着这两份文本。"
)
+121
View File
@@ -0,0 +1,121 @@
"""模型调用接缝的行为契约。
**这一层不打真实网关**——那是 e2e 的事。这里断言的是返回结构体的形状与失败的表达方式,
用一个受控替身就能验。
两个已知形态:一个按三本账各记一条并自己按价格表算成本,一个在调用外面套退避并累加本次
运行的 token。
"""
import asyncio
import inspect
import pytest
from polyloop.ports import ModelCall, ModelClient
from polyloop.testing._records import ContractBase
class ModelClientContract(ContractBase):
"""模型调用接缝的准入套件。下游继承它,覆盖 `model_client` 与 `failing_call`。"""
@pytest.fixture
def model_client(self) -> ModelClient:
"""被测的模型调用接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。"""
raise NotImplementedError(
"在你的子类里覆盖 `model_client` fixture,返回一个 ModelClient 实现的实例。"
)
@pytest.fixture
def failing_call(self) -> ModelCall:
"""一次拿去调用被测客户端会失败的调用,由实现方提供。
**套件不许自己写死它。** 一个实现要怎样才会失败,只有它自己知道:可能是一个不存在的
模型名、一条空得过不了校验的消息序列、一个指向黑洞的端点。套件这边随便定一个暗号
(比如约定 `result_id` 等于某个特定串时就该抛),等于替所有实现定了一份它们从没同意过
的协议——一个真网关适配器不认识那个暗号,于是它调用成功、用例判它不合格,而它其实是
对的。
返回一个 `polyloop.ports.ModelCall`。拿它调用被测客户端**必须抛异常**,抛什么类型由
实现定,契约只要求「抛」。
**这次调用不许真的花钱**,也不许在失败之前留下外部副作用。失败要发生在调用真的打出去
之前或之中,最省的做法是让它在入参校验那一关就挂掉。
**它和别的用例共用同一个 `model_client` 实例**,所以失败之后那个实例必须还能接着服务:
一次失败的调用把客户端弄成不可用,本身就不合格。
**退化情况:不存在「无论如何都不会失败」的合规实现。** 契约规定失败以异常表达,不以
「返回一个内容为空的正常回复」表达——库靠这个区分基础设施故障与「模型真的回了空字符
串」,而这两者在分析里属于完全不同的类别。所以一个给不出 `failing_call` 的实现,要么
是把失败吞成了空回复(那就是不合格,正是这条用例要抓的),要么是还没想过失败路径。
真的构造不出来时,最接近的合规做法是让实现在入参校验那一关抛,而不是把这条用例重写掉。
"""
raise NotImplementedError(
"在你的子类里覆盖 `failing_call` fixture,返回一个拿去调用被测客户端会抛异常的 "
"ModelCall。"
)
async def test_returns_three_fields(self, model_client, records):
"""返回三个字段:调用标识、可见回复、推理段。
可见回复与推理段的长度由库自己数字符,不从任何用量对象取——实测中转网关会用本地分词器
补算并整体替换用量对象,把明细一起吃掉,某次标定里 24 次调用的推理 token 全部没上报。
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert isinstance(reply.content, str)
assert isinstance(reply.thinking, str)
assert reply.call_id is None or isinstance(reply.call_id, str)
async def test_call_id_is_never_an_empty_string(self, model_client, records):
"""调用标识可以是「没有」,但绝不能是空串。
它是轨迹与账目之间唯一的连接键。空串是个「看起来合法」的键,连表时静默匹配不上;显式
的「没有」至少能被筛出来。它为空的合法含义只有一个:调用在记账之前就失败了。
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert reply.call_id != ""
async def test_failure_is_expressed_as_an_exception(self, model_client, failing_call):
"""调用失败以异常表达,不以「返回一个空回复」表达。
库接住它、翻译成模型故障、记一条调用标识为空的步。如果失败被表达成一个内容为空串的
正常返回,库没有任何办法把它和「模型真的回了空字符串」分开——而后者是模型行为,前者
是基础设施故障,两者在分析里属于完全不同的类别。
"""
with pytest.raises(Exception): # noqa: B017 具体异常类型归实现,契约只要求「抛」
await model_client.call(failing_call)
async def test_cancellation_propagates_and_is_not_swallowed(self, model_client, records):
"""取消要能穿过模型调用,`CancelledError` 不许被捕获吞没。
**`cancel()` 返回 `False` 是能力判定,不是失败。** 一个在单个事件循环 tick 之内就返回
的客户端(一个受控替身就是),取消发出去时它已经跑完,没有任何机会吞掉取消。把它判成
不合格会让这条用例的成败取决于实现跑得多快,而一个偶发变红的准入标准会训练下游忽略红。
"""
task = asyncio.ensure_future(
model_client.call(records.model_call(call_index=0, result_id="m0"))
)
await asyncio.sleep(0)
if not task.cancel():
pytest.skip(
"承诺:取消要能穿过模型调用,CancelledError 不许被捕获吞没。"
"这一次验不了——被测实现在一个事件循环 tick 之内就返回了,取消发出去时它已经跑完,"
"根本没有机会吞掉取消。跑得太快不是违约,这条契约对它无从谈起,不必去改实现。"
"自己验:只有在实现真的有等待点(网络往返、子进程、容器会话)时这条才验得出来。"
)
with pytest.raises(asyncio.CancelledError):
await task
def test_signature_carries_no_retry_or_rate_limit_parameters(self, model_client):
"""签名里不出现重试次数、退避时长、限流配额。
出现即意味着库在治理一次模型调用,而那归 PolyGateway`CLAUDE.md` §1.5)。这条断言的是
名字,不是行为——按 §1.8,公共 Protocol 的签名本身就是对下游的承诺,断言它是应该的。
"""
names = set(inspect.signature(model_client.call).parameters)
assert not (names & {"retries", "max_retries", "backoff", "timeout", "rate_limit"})
+26
View File
@@ -0,0 +1,26 @@
"""pytest 插件入口。它什么都不做,存在的理由全在这段话里。
`pyproject.toml` 的 `[project.entry-points.pytest11]` 指向这个模块。pytest 启动时会先扫一遍
所有声明了 `pytest11` 入口的发行包,把它们的每一个 `.py` 文件标记为**断言可重写**,然后才
加载插件本身。换回来的就是这个标记:契约模块不在下游的 `python_files` 匹配范围里,默认不被
重写,于是一条契约用例失败时下游看到的是光秃秃的 `AssertionError`——没有等号两边的值,没有
差异摘要。
**删掉这个模块不会有任何东西报错**,下游只会觉得这套契约的报错难读,而且永远不会知道自己
少了什么。这是一次纯静默的退化,所以它值得一个只有 docstring 的模块。
**这个模块只 import pytest 与标准库。** 不 import 任何存储实现、不 import `polyloop.session`、
不 import `polyloop.adapters`——最后一条尤其硬:`adapters` 会把网关连同它的 provider 目录一起
拉起来,而 pytest 自动加载插件是那条依赖规则的一条新触发路径。这条边界不靠自觉,
import-linter 的分层契约与「除 testing 外一切禁止 import pytest」两条契约把它钉住。
**这里不接管事件循环**,理由见 `research-wiki/design/0014-contract-suite-distribution.md`
决策三末尾:下游的异步 fixture 在它自己的循环里创建,库另开一个循环会让那些 asyncio 对象
跨循环,而跨循环的对象炸得很难查。
"""
import pytest
def pytest_configure(config: pytest.Config) -> None:
"""空钩子。断言重写在 pytest 加载插件之前就按发行包做完了,这里没有要补的事。"""
@@ -1,46 +1,31 @@
"""契约套件的装配点。 """构造各类记录的工厂,以及把它递给用例的那个共同父类。"""
这套测试**不针对任何具体实现**它写的是不管你怎么实现都必须满足这些行为所以它
自己不造实现只声明你得提供什么任何一个下游写完自己的存储或适配器把它接到这里
fixture 上跑一遍全绿就算合格这是 `CLAUDE.md` §0 说的任何新适配器的准入标准
**接法**下游在自己的 `conftest.py` 里覆盖同名 fixture返回自己的实现
**为什么在实现之前就写它**写一条契约测试要求把每一次调用逐字写出来方法叫什么参数
填什么返回值怎么取散文里读着通顺的地方落到这一步就会露出来前三轮文档评审抓不到的
几乎全是这么冒出来的
"""
from collections.abc import Mapping from collections.abc import Mapping
import pytest import pytest
from polyloop.ports import Action, Event, EventKind, ToolCall from polyloop.ports import Action, Event, EventKind, ModelCall, ToolCall
from polyloop.stores import JsonlRunStore
from polyloop.types import ( from polyloop.types import (
ActionOutcome, ActionOutcome,
ActionStatus, ActionStatus,
Intent, Intent,
IntentKind, IntentKind,
Message,
ModelCallResult, ModelCallResult,
ModelReply, ModelReply,
ReplayPolicy, ReplayPolicy,
Role,
RunFinished, RunFinished,
RunResult, RunResult,
RunStarted, RunStarted,
StepCompleted, StepCompleted,
StepRecord, StepRecord,
StopReason, StopReason,
) TextBlock,
#: 还没有默认实现的那几个接缝,套件里对应的测试全部跳过。
_NO_IMPLEMENTATION = (
"库不带这个接缝的默认实现——带了就等于替某一家定了它的协议。"
"下游在自己的 conftest.py 里覆盖这个 fixture,把自己的实现接进来跑。"
) )
class _Records: class RecordFactory:
"""构造各类记录的工厂。 """构造各类记录的工厂。
它不是被测对象是让测试正文读得懂的一层薄封装`records.model_call_intent(...)` 比直接 它不是被测对象是让测试正文读得懂的一层薄封装`records.model_call_intent(...)` 比直接
@@ -54,6 +39,36 @@ class _Records:
def reply(self, *, call_id: str | None = "call-1", content: str = "hi") -> ModelReply: def reply(self, *, call_id: str | None = "call-1", content: str = "hi") -> ModelReply:
return ModelReply(call_id=call_id, content=content, thinking="") return ModelReply(call_id=call_id, content=content, thinking="")
def model_call(
self,
*,
call_index: int,
result_id: str,
run_id: str = "r1",
messages: tuple[Message, ...] | None = None,
binding: Mapping[str, str] | None = None,
) -> ModelCall:
"""造一次交给模型调用接缝的调用。
`run_id` 有默认值而记录类的那几个工厂方法一律要求显式传区别在于这个壳不进任何
一份日志没有哪条用例靠它区分两个运行的记录记录类那边的默认值会让
两个运行互相看不见对方那条用例悄悄退化成同一个运行写了两次
`messages` 默认给一条用户消息而不是空序列一个真实的实现拿到空消息序列多半直接
拒绝于是这条用例验的就变成了它的入参校验不是它的返回结构
"""
return ModelCall(
messages=(
messages
if messages is not None
else (Message(role=Role.USER, content=(TextBlock(text="你好"),)),)
),
call_index=call_index,
run_id=run_id,
result_id=result_id,
binding=dict(binding) if binding is not None else {},
)
def model_call_intent(self, *, run_id: str, call_index: int, result_id: str) -> Intent: def model_call_intent(self, *, run_id: str, call_index: int, result_id: str) -> Intent:
return Intent( return Intent(
run_id=run_id, run_id=run_id,
@@ -164,65 +179,15 @@ class _Records:
) )
@pytest.fixture class ContractBase:
def records() -> _Records: """五个契约基类的共同父类,只为把记录工厂递给用例。
return _Records()
它不是 `research-wiki/design/0014-contract-suite-distribution.md` 决策二第七条否掉的那种
@pytest.fixture fixture mixin那条否的是让下游在自己的子类上多继承一个 fixture 这个父类下游既看不见
def store(tmp_path) -> JsonlRunStore: 也用不着`records` 由套件自己提供没有哪个实现需要覆盖它
"""被测的存储接缝实现。
默认接的是库自带的那个逐行追加实现下游覆盖这个 fixture返回自己的实例
每次调用返回一个**空的**存储套件里每条测试都假设自己面对一份干净的日志共用状态会让
测试之间的顺序变成隐式依赖`tmp_path` 每条测试一个新目录这一条自动成立
""" """
return JsonlRunStore(directory=tmp_path)
@pytest.fixture @pytest.fixture
def samples(): def records(self) -> RecordFactory:
"""被测解释器认得的几段模型输出,由实现方提供。 """记录工厂。套件自己提供,不需要在子类里覆盖。"""
return RecordFactory()
**套件不许自己写死输入** 库不带默认解释器实现也就不认识任何一家的动作语言拿一家的
代码围栏去喂另一家的 JSON 解析器后者正确地返回无效决策而写死输入的套件会把这个
正确行为判成失败
实现方要提供两段`yields_an_action`一段能被解释成动作的模型回复 `yields_invalid`
一段解释不出动作的这不是给套件开后门我这套语言里什么算合法动作本来就只有
实现方答得出套件断言的是**拿到之后的形状**不是输入长什么样
"""
pytest.skip(_NO_IMPLEMENTATION)
@pytest.fixture
def action_executor():
"""被测的动作执行接缝实现。
库自带一个由工具注册表派生的分发器`polyloop.tools.ToolRegistry.executor`但它只覆盖
工具调用那一种动作语言把它接在这里会让套件只验得了那一种所以默认仍然留空
"""
pytest.skip(_NO_IMPLEMENTATION)
@pytest.fixture
def decision_parser():
"""被测的决策解释接缝实现。"""
pytest.skip(_NO_IMPLEMENTATION)
@pytest.fixture
def model_client():
"""被测的模型调用接缝实现。
注意这一层的契约测试**不打真实网关**那是 e2e 的事这里断言的是返回结构体的形状与
失败时的表达方式用一个受控的替身就能验
"""
pytest.skip(_NO_IMPLEMENTATION)
@pytest.fixture
def event_sink():
"""被测的事件出口实现。"""
pytest.skip(_NO_IMPLEMENTATION)
+247
View File
@@ -0,0 +1,247 @@
"""存储接缝的行为契约。
这套用例是「一次运行的日志到底保证什么」的权威(`CLAUDE.md` §0)。两个已知实现形态差别
很大——一个逐行追加本地文件,一个写关系数据库——所以下面每一条都只说行为,不碰形态。
**行为的理由不在这里。** 崩溃恢复为什么这么设计见 `design/0002`,写入粒度与前缀持久性见
`design/0005`。这里只断言结果。
## 无条件跳过的那两条
它们是**已知没有机器兜底的承诺**,不是还没写的测试。写成跳过而不是一句注释,是为了让它们
在每次跑套件时都被看见——`pytest -rs` 会把跳过的理由列出来。
不写成 `xfail`,因为一个做得比库预期更好的实现会把这种测试跑通,于是拿到 XPASS 判失败,
而下游取消不掉:子类上加一个类级 xfail 覆盖不了从函数级继承下来的那个,唯一的出路是整条
重写方法。语义上跳过也更准——这两条说的不是「这个功能预期会失败」,而是「套件所在的这一层
没有能力验证它」。
剩下那条曾经答不上的——没有动作的步 `StepCompleted.result_id` 填什么——已经由 `design/0006`
决策七答掉(可为空,且为空当且仅当动作结果也为空),对应的测试已经改写成真断言。
"""
import pytest
from polyloop.ports import RunStore
from polyloop.testing._records import ContractBase
class RunStoreContract(ContractBase):
"""存储接缝的准入套件。下游继承它,覆盖 `store`。"""
@pytest.fixture
def store(self) -> RunStore:
"""被测的存储接缝实现。**在你的子类里覆盖这个 fixture**,返回你自己的实例。
**每次调用要返回一个空的存储。** 套件里每条用例都假设自己面对一份干净的日志,共用
状态会让用例之间变成隐式的顺序依赖——那种依赖只在换个顺序跑的那天才暴露,而那天
通常是加了一条新用例之后,于是错误看起来来自那条新用例。
"""
raise NotImplementedError(
"在你的子类里覆盖 `store` fixture,返回一个 RunStore 实现的实例;"
"每次调用都要返回一个空的存储。"
)
# ----------------------------------------------------------------------
# 一、写进去的读得回来
# ----------------------------------------------------------------------
async def test_written_intent_is_readable(self, store, records):
"""写一条意图,读回整份日志时它必须在里面。
这是全套最基本的一条:意图日志的全部意义是「比进程活得久」,写了读不回来,后面每一条
恢复语义都建立在空气上。
"""
intent = records.model_call_intent(run_id="r1", call_index=0, result_id="m0")
await store.write_intent(intent)
log = await store.read_log("r1")
assert intent in log.intents
async def test_log_of_unknown_run_is_empty_not_an_error(self, store):
"""读一个从没写过的运行标识,得到一份空日志,而不是异常。
`run` 在开工前要判断「这个标识是不是已经有日志了」,靠的就是这一条。如果读不存在的
运行会抛异常,那个判断就得写成捕获异常——而捕获异常来做流程控制,会把真正的存储故障
一起吞掉。
"""
log = await store.read_log("never-written")
assert log.started is None
assert log.intents == ()
assert log.finished is None
async def test_two_runs_do_not_leak_into_each_other(self, store, records):
"""两个运行标识各写各的,互相看不见对方的记录。
端口不持有「当前运行」的隐式状态,这条测试是那个要求的外部可观测形式。一个有隐式当前
运行的实现会在并发下把 A 的意图写进 B 的日志,而那种错在单线程测试里永远不出现。
"""
a = records.model_call_intent(run_id="run-a", call_index=0, result_id="m0")
b = records.model_call_intent(run_id="run-b", call_index=0, result_id="m0")
await store.write_intent(a)
await store.write_intent(b)
assert (await store.read_log("run-a")).intents == (a,)
assert (await store.read_log("run-b")).intents == (b,)
# ----------------------------------------------------------------------
# 二、四态:恢复靠「意图有没有 / 结果有没有」判定
# ----------------------------------------------------------------------
async def test_intent_without_result_is_readable_as_such(self, store, records):
"""写了意图、没写结果,读回来必须能看出「这个 ID 没有结果」。
这是四态表里「状态未知」那一档的输入。存储不负责判定,但它必须让判定问得出口——恢复
要按预分配的 ID 精确地问,而不是模糊匹配去猜哪条结果对应哪次执行。
"""
intent = records.action_intent(run_id="r1", call_index=0, result_id="a0")
await store.write_intent(intent)
log = await store.read_log("r1")
assert intent in log.intents
assert all(step.result_id != "a0" for step in log.steps)
async def test_result_without_intent_is_visible_to_the_reader(self, store, records):
"""只写结果不写意图,读回来必须原样可见,存储自己不许修复也不许拒收。
「有结果没意图」是日志损坏,处置是拒绝续跑——但那个判断归恢复逻辑,不归存储。存储在
这里悄悄补一条意图或者拒绝这次写入,都会让损坏变得不可见,而不可见的损坏会被当成
正常数据继续用下去。
"""
result = records.model_call_result(run_id="r1", result_id="orphan", reply=records.reply())
await store.write_model_call_result(result)
log = await store.read_log("r1")
assert log.model_results == (result,)
assert log.intents == ()
async def test_failed_model_call_is_recorded_as_a_result_not_as_nothing(self, store, records):
"""模型调用失败也要落一条结果记录,否则恢复会把它读成「状态未知」。
失败这件事是确定的:调用发出去了、失败了、库记了一条步。如果这时不写结果条目,恢复
只看见「意图有、结果无」,走重放策略——而这次调用的状态一点都不未知。下游按停止原因
做的统计会照单收下这个错误。
"""
result = records.model_call_result(
run_id="r1", result_id="m0", reply=None, failure="连接超时"
)
await store.write_model_call_result(result)
(readback,) = (await store.read_log("r1")).model_results
assert readback.reply is None
assert readback.failure == "连接超时"
# ----------------------------------------------------------------------
# 三、原子写
# ----------------------------------------------------------------------
async def test_action_result_and_step_land_together(self, store, records):
"""动作结果与步记录一次原子落地:读回来要么两者都在,要么都不在。
不原子的话,崩在两者之间会让那一步的历史文本永远丢失,而恢复判定会把它读成「执行完了,
跳过」——恢复出来的消息序列比不中断跑完时少一轮,后面每一步都跟着偏。
**这条测试只能验「一起可见」,验不了「一起不可见」。** 见本文件末尾那条。
"""
step = records.step_completed(
run_id="r1",
result_id="a0",
action_outcome=records.outcome(),
step=records.step(step_idx=0),
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].action_outcome is not None
async def test_step_without_an_action_is_still_recorded(self, store, records):
"""没有动作的步照样留痕:解析失败、模型调用失败、最终回答三种都算一步。
预算对等要求它们计入步数——它们确实消耗了一次模型调用。丢掉那一步还会丢掉模型在出故障时
说了什么,而那正是排查「环境坏了还是模型写了危险代码」最需要的。
这条曾经写不出来:那时 `StepCompleted.result_id` 是必填字符串,而这一步没写过动作意图、
没有预分配的 ID,随便编一个会让恢复读到一条对不上任何意图的记录,按四态表最后一行判成
日志损坏。`design/0006` 决策七把它改成可为空,并要求**它为空当且仅当动作结果也为空**,
这个洞才补上。存储要能原样存下这个形状。
"""
step = records.step_completed(
run_id="r1", result_id=None, action_outcome=None, step=records.step(step_idx=0)
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].result_id is None
assert log.steps[0].action_outcome is None
# ----------------------------------------------------------------------
# 四、运行的开始与结束
# ----------------------------------------------------------------------
async def test_run_finished_is_visible_before_the_result_is_returned(self, store, records):
"""「这次运行结束了」这个标记由库写下,而且写在把结果交给调用方之前。
另一条路有个具体的失败场景:结果由项目落盘的话,「跑完了、库返回了、项目存的时候崩了」
这种情况下,重启后日志显示最后一步有结果、没有结束标记,而项目那边什么都没有。续跑会
重复执行最后一步的副作用,不续跑就丢掉一次已经花完钱的运行。歧义来自结果跨了两个存储。
"""
finished = records.run_finished(run_id="r1", result=records.result(run_id="r1"))
await store.write_run_finished(finished)
assert (await store.read_log("r1")).finished == finished
async def test_run_started_carries_the_parameter_snapshot(self, store, records):
"""运行开始记录带着这次的参数快照,续跑时拿它与当前装配比对。
没有它,用同一个运行标识换一份定义续跑,前几步与后几步会来自两个不同的配置而全程零
报错——那正是要到统计阶段才分不清哪些行是真的那类损坏。
"""
started = records.run_started(run_id="r1", parameter_snapshot={"model": "m-1"})
await store.write_run_started(started)
assert (await store.read_log("r1")).started.parameter_snapshot == {"model": "m-1"}
# ----------------------------------------------------------------------
# 五、这套测试**验不了**的两条承诺
# ----------------------------------------------------------------------
def test_atomicity_under_crash_is_not_checkable_here(self):
"""原子性的另一半——「崩在中间时两者都不可见」——这一层验不了。
要验它得在写入过程中把进程杀掉,而契约测试跑在一个进程里、面对的是一个已经装配好的
实现,没有位置插入那次崩溃。给端口加一个「故意在这里失败」的钩子能验,但那个钩子会
变成公共 API 的一部分,而它只为测试存在。
结论是这条承诺**没有机器兜底**,只能靠 `CLAUDE.md` §3 那轮对抗审查看实现。把这件事
写成一条跳过而不是一句注释,是为了让它在每次跑套件时都被看见。
"""
pytest.skip(
"承诺:动作结果与步记录一起落地,崩在中间时两者都不可见。"
"这一层验不了——套件跑在一个进程里、面对一个装配好的实现,没有位置插入那次崩溃,"
"而为它加一个「故意在这里失败」的钩子会把测试用的东西变成公共 API。"
"自己验:在你自己实现的单元测试里用可注入的故障点覆盖它,"
"或者按 CLAUDE.md §3 第四类走一轮对抗审查看实现。"
)
def test_prefix_durability_is_not_checkable_here(self):
"""前缀持久性同样验不了,理由更硬一层。
它说的是「第 k 次写入被确认持久时,前 k-1 次也已经持久」,而「已经持久」是掉电之后
才看得出来的性质。在一个进程里读得回来,不等于它落了盘。
"""
pytest.skip(
"承诺:第 k 次写入被确认持久时,第 1 到 k-1 次也已经持久。"
"这一层验不了——「已经持久」是掉电之后才看得出来的性质,"
"在一个进程里读得回来不等于它落了盘。"
"自己验:它实际是对实现形态的约束(同一文件的追加写、同一连接上顺序提交的事务"
"天然满足它),靠评审看你的实现属不属于这种形态。"
)
-97
View File
@@ -1,97 +0,0 @@
"""动作执行接缝的行为契约。
两个已知形态差别很大:一个把一段代码交给已经开好的容器会话、状态恒为「已执行」,一个查
工具注册表分发、工具不存在或参数不合法时返回「未执行」。下面每一条都要对两者同时成立。
## 写这份文件时撞出来的两个问题,`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`,不在这个接缝的
契约里。
"""
-84
View File
@@ -1,84 +0,0 @@
"""决策解释接缝的行为契约。
两个已知形态:一个从代码围栏里抽 Python 源码,一个从 JSON 里抽工具名与参数。库不带任何
默认实现——带了就等于替某一家定了动作语言。
## 写这份文件时撞出来的那个问题,`design/0007` 决策三答了
模型输出完全无法解释时,解释器返回「无效决策」,不抛异常。下面最后一条断言它。
"""
import pytest
from polyloop.ports import InvalidDecision
pytestmark = pytest.mark.contract
def test_parse_is_synchronous(decision_parser, samples):
"""`parse` 是同步的,不是协程。
解释一次模型回复是纯计算,没有等待点。写成协程会让每个只想写测试替身的下游多套一层
`async def`,也会诱导实现方在里面做 I/O——而这个接缝一旦做起 I/O,「恢复时重新解释
被打断的那一步」就不再是安全操作了。
"""
parsed = decision_parser.parse(samples.yields_an_action)
assert not hasattr(parsed, "__await__")
def test_history_text_is_what_goes_back_into_the_conversation(decision_parser, samples):
"""`history_text` 是这一步回填进历史的那段文本,可以与模型原文不同。
解释器有权改写它:dissect 的解析器把第一个代码围栏之后的内容整段丢掉,因为模型常在
代码块后面编造「执行结果」。库这边只有模型原文,照它回填,模型下一轮会看见自己编的
那段,而迁移前它看不见。
**输入由被测实现自己提供**,不由套件写死。库不带默认实现,也就不认识任何一家的动作
语言——拿 dissect 的代码围栏去喂 GovDoc 的 JSON 解析器,它正确地返回「无效决策」,
而套件会把这个正确行为判成失败。
"""
reply = samples.yields_an_action
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, samples):
"""无效决策的说明文本**就是**回喂给模型的那段观察,不是从一个固定串里取。
dissect 的解析器对五种解析失败各有一条对症说明(没有代码块、空的未闭合块、闭合围栏后
跟了别的内容、多块策略下第一块为空、拼接策略下全空)。压成一句会改掉它的实验条件——
模型收到的纠错信息变了,它的纠错行为也就变了。
"""
parsed = decision_parser.parse(samples.yields_invalid)
assert isinstance(parsed.decision.explanation, str)
assert parsed.decision.explanation != ""
def test_action_carries_its_trace_form(decision_parser, samples):
"""动作分支要带「这一步的动作在轨迹里长什么样」,由实现方决定内容,库原样填进步记录。
dissect 传那段 Python 源码,GovDoc 传序列化后的参数。没有这个字段,dissect 轨迹里那一列
会被库改写,而那个文件是它的反思模型的唯一输入界面。
"""
parsed = decision_parser.parse(samples.yields_an_action)
assert isinstance(parsed.decision.text, str)
def test_unparseable_output_returns_invalid_decision_rather_than_raising(decision_parser, samples):
"""模型输出完全无法解释时返回「无效决策」,不抛异常(`design/0007` 决策三)。
两条路后果完全不同:返回无效决策,那一步照常留痕、说明文本回喂给模型、循环继续;抛
异常,库要么把它翻译成某个停止原因终止整次运行,要么让它穿出去炸掉调用方。
dissect 的解析器不抛异常,所以它撞不到这个分歧。但契约测试是**任何新适配器的准入
标准**,所以这条要正面断言,不能靠「反正没人这么写」。
"""
parsed = decision_parser.parse(samples.yields_invalid)
assert isinstance(parsed.decision, InvalidDecision)
assert parsed.decision.explanation != ""
@@ -0,0 +1,72 @@
"""把决策解释契约接到一个测试替身上。
库不带这个接缝的实现——带了就等于替某一家定了动作语言。所以这里造一个最小的替身,它存在的
唯一目的是让套件的每一条用例至少被真的求值一次:一条引用了记录工厂里不存在的方法的用例,
只有在被执行的时候才会红。
**这个替身住在 `tests/` 里,不进 wheel,任何下游都拿不到它。** 「库不带默认实现」那条禁的是
`src/` 下出现一个能用的实现——下游装了包就拿得到,就会有人直接用,于是动作语言被库替它定了。
判据是下游拿不拿得到,不是代码库里有没有一个能跑的实现
`research-wiki/design/0014-contract-suite-distribution.md` 决策六)。
`contract` 这个标记打在本文件上,不打在套件里,理由见 `test_run_stores.py`。
"""
from collections.abc import Mapping
from dataclasses import dataclass
import pytest
from polyloop.ports import Action, InvalidDecision, ParsedReply
from polyloop.testing import DecisionParserContract, RecordFactory
from polyloop.types import ModelReply
#: 这个替身认得的全部动作语言:正文以它开头就是一个动作,剩下的部分是动作本身。
_ACTION_PREFIX = "DO "
pytestmark = pytest.mark.contract
class _PrefixDecisionParser:
"""认一种一行前缀的动作语言,别的一律解释不出动作。
**两支都要走得到**,这是契约对样本的要求在替身这一侧的对应:一个「什么都解释得出来」的
实现会让无效决策那一支变成死代码,而套件照样绿,绿的含义从「这一支对」变成「这一支没验」。
它满足 `polyloop.ports.DecisionParser`,但不显式继承那个 Protocol:结构化子类型不需要继承。
"""
def parse(self, reply: ModelReply) -> ParsedReply:
if reply.content.startswith(_ACTION_PREFIX):
return ParsedReply(
history_text=reply.content,
decision=Action(text=reply.content[len(_ACTION_PREFIX) :], tool_call=None),
)
return ParsedReply(
history_text=reply.content,
decision=InvalidDecision(
explanation=f"这段回复没有以 {_ACTION_PREFIX!r} 开头,解释不出动作"
),
)
def parameters(self) -> Mapping[str, str]:
return {"prefix": _ACTION_PREFIX}
@dataclass(frozen=True, slots=True, kw_only=True)
class _ReplySamples:
yields_an_action: ModelReply
yields_invalid: ModelReply
class TestPrefixDecisionParser(DecisionParserContract):
@pytest.fixture
def decision_parser(self) -> _PrefixDecisionParser:
return _PrefixDecisionParser()
@pytest.fixture
def reply_samples(self, records: RecordFactory) -> _ReplySamples:
return _ReplySamples(
yields_an_action=records.reply(content=f"{_ACTION_PREFIX}做点事"),
yields_invalid=records.reply(content="我先想想。"),
)
-58
View File
@@ -1,58 +0,0 @@
"""事件出口的行为契约。
两个已知形态差别在可靠性要求上:一个把进度逐步回写业务数据库供前端轮询(要求低延迟、
可以丢),一个把审计事件送进日志管道(要求不丢、可以慢)。
## 写这份文件时撞出来的问题,`design/0013` 答了
「发出去的事件里有什么」当时验不了,因为 `Event` 只有一个名字没有字段。现在事件集定下来了,
而答案把这份文件里的两条测试都挪走了——它们要断言的行为都在库那一侧,不在出口这一侧,见文末
那两条说明。
"""
import pytest
pytestmark = pytest.mark.contract
async def test_emit_accepts_an_event(event_sink, records):
"""能收下一个事件,正常路径不抛异常。"""
await event_sink.emit(records.event())
def test_a_raising_sink_is_compliant_so_this_layer_asserts_nothing():
"""**这一层不断言「emit 不抛」——一个后端连不上时抛异常的出口是合规实现。**
契约写的是「投递失败由**库**捕获、记日志、把失败计数加一,然后继续跑」,所以要断言的
行为在库那一侧,不在出口这一侧。原来这里写了一条 `await emit(...)` 不抛的断言,那会把
一个完全合法的审计 sink 判失败——它在日志管道不可用时抛 `ConnectionError`,而库本来就
该接住。
「库接住了失败并继续跑」属于整次运行的行为,落在驱动入口那一层的测试里,不在这个接缝的
契约里。这条留成一个说明,是为了让下一个想在这儿加断言的人先看到这段。
"""
def test_the_no_re_emission_guarantee_is_asserted_in_the_library_not_here():
"""投递失败不再转成一条事件从同一个出口发出去(`design/0013` 决策七)。
那会自我喂食:一个持续失败的出口会让失败处理路径变成递归,而递归的表现是进程卡住或
栈溢出,不是一条错误日志。
**要断言的是库有没有再发一次,那是整次运行的行为**,所以断言在
`tests/unit/test_session.py` 里——那边用一个恒抛异常的出口跑完一次运行,验出口收到的
条数恰好等于步数。这个接缝自己看不到「库发了几次」。
"""
def test_the_audit_trail_is_asserted_against_the_log_not_here():
"""审计纪律由存储承担,不由事件流承担(`design/0013` 决策二)。
GovDoc 有一条硬纪律:agent 的原始输出、修复后的输出、恢复来源全程留痕,禁止静默修复。
这条测试原来断言「事件要同时带原文与修复后的文本」,而那个前提是错的——事件流可丢,
一件只存在于可丢通道里的事实撑不起「禁止静默修复」。
两份文本在意图日志里各有位置:原文在模型调用结果记录的回复里,修复后的那份是步记录的
`raw_output`。断言落在 `tests/unit/test_session.py`,因为要跑完一次完整运行再把日志读
回来,而这个接缝的契约只看得见一个出口实现。
"""
+47
View File
@@ -0,0 +1,47 @@
"""把事件出口契约接到一个测试替身上。
库不带这个接缝的实现——带了就等于替某一家定了投递协议。这里造一个最小的替身,它存在的唯一
目的是让套件的每一条用例至少被真的求值一次。
**这个替身住在 `tests/` 里,不进 wheel,任何下游都拿不到它。** 「库不带默认实现」那条禁的是
`src/` 下出现一个能用的实现——下游装了包就拿得到,就会有人直接用。判据是下游拿不拿得到,
不是代码库里有没有一个能跑的实现
`research-wiki/design/0014-contract-suite-distribution.md` 决策六)。
`contract` 这个标记打在本文件上,不打在套件里,理由见 `test_run_stores.py`。
"""
from collections.abc import Mapping
import pytest
from polyloop.ports import Event
from polyloop.testing import EventSinkContract
pytestmark = pytest.mark.contract
class _CollectingEventSink:
"""收下事件,攒进一个列表。
**不做别的**:这套契约只断言「收得下一个事件」,而「投递失败之后库还在跑」「库没有把失败
再发一次」都是整次运行的行为,由库自己的测试守着,不由一个出口实现验。往这里加重试、加
过滤、加计数,验的就变成这个替身自己了。
它满足 `polyloop.ports.EventSink`,但不显式继承那个 Protocol:结构化子类型不需要继承。
"""
def __init__(self) -> None:
self.events: list[Event] = []
async def emit(self, event: Event) -> None:
self.events.append(event)
def parameters(self) -> Mapping[str, str]:
return {"kind": "collecting"}
class TestCollectingEventSink(EventSinkContract):
@pytest.fixture
def event_sink(self) -> _CollectingEventSink:
return _CollectingEventSink()
-74
View File
@@ -1,74 +0,0 @@
"""模型调用接缝的行为契约。
**这一层不打真实网关**——那是 e2e 的事。这里断言的是返回结构体的形状与失败的表达方式,
用一个受控替身就能验。
两个已知形态:一个按三本账各记一条并自己按价格表算成本,一个在调用外面套退避并累加本次
运行的 token。
"""
import pytest
pytestmark = pytest.mark.contract
async def test_returns_three_fields(model_client, records):
"""返回三个字段:调用标识、可见回复、推理段。
可见回复与推理段的长度由库自己数字符,不从任何用量对象取——实测中转网关会用本地分词器
补算并整体替换用量对象,把明细一起吃掉,某次标定里 24 次调用的推理 token 全部没上报。
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert isinstance(reply.content, str)
assert isinstance(reply.thinking, str)
assert reply.call_id is None or isinstance(reply.call_id, str)
async def test_call_id_is_never_an_empty_string(model_client, records):
"""调用标识可以是「没有」,但绝不能是空串。
它是轨迹与账目之间唯一的连接键。空串是个「看起来合法」的键,连表时静默匹配不上;显式
的「没有」至少能被筛出来。它为空的合法含义只有一个:调用在记账之前就失败了。
"""
reply = await model_client.call(records.model_call(call_index=0, result_id="m0"))
assert reply.call_id != ""
async def test_failure_is_expressed_as_an_exception(model_client, records):
"""调用失败以异常表达,不以「返回一个空回复」表达。
库接住它、翻译成模型故障、记一条调用标识为空的步。如果失败被表达成一个内容为空串的
正常返回,库没有任何办法把它和「模型真的回了空字符串」分开——而后者是模型行为,前者
是基础设施故障,两者在分析里属于完全不同的类别。
"""
with pytest.raises(Exception): # noqa: B017 具体异常类型归实现,契约只要求「抛」
await model_client.call(records.model_call(call_index=0, result_id="fail"))
async def test_cancellation_propagates_and_is_not_swallowed(model_client, records):
"""取消要能穿过模型调用,`CancelledError` 不许被捕获吞没。"""
import asyncio
task = asyncio.ensure_future(
model_client.call(records.model_call(call_index=0, result_id="m0"))
)
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
def test_signature_carries_no_retry_or_rate_limit_parameters(model_client):
"""签名里不出现重试次数、退避时长、限流配额。
出现即意味着库在治理一次模型调用,而那归 PolyGateway`CLAUDE.md` §1.5)。这条断言的是
名字,不是行为——按 §1.8,公共 Protocol 的签名本身就是对下游的承诺,断言它是应该的。
"""
import inspect
names = set(inspect.signature(model_client.call).parameters)
assert not (names & {"retries", "max_retries", "backoff", "timeout", "rate_limit"})
+81
View File
@@ -0,0 +1,81 @@
"""把动作执行契约接到库自带的分发器上。
`RegistryExecutor` 是库里唯一一个动作执行器实现:它按工具名查注册表、校验参数、调那个工具的
实现。套件不认识任何一家的动作语言,所以两个样本动作由这里提供——都是工具调用,因为那是这个
执行器唯一认得的形状。
`contract` 这个标记打在本文件上,不打在套件里,理由见 `test_run_stores.py`。
"""
from collections.abc import Mapping
from dataclasses import dataclass
import pytest
from polyloop.ports import Action
from polyloop.testing import ActionExecutorContract, RecordFactory
from polyloop.tools import RegistryExecutor, ToolRegistry, ToolSpec
pytestmark = pytest.mark.contract
#: 两个工具都不收参数,校验那一段因此不参与这套用例的成败。
_NO_ARGUMENTS: Mapping[str, object] = {
"type": "object",
"properties": {},
"additionalProperties": False,
}
async def _returns_text(arguments: Mapping[str, object]) -> str:
"""跑得完、不报错的那个工具。
**它里面没有等待点,这是刻意的。** 一个纯计算的工具本来就没有可挂起的地方,而取消那条
用例会因此判定「这个实现快到没有可取消的窗口」并跳过——跑得太快不是违约。往这里塞一句
`await asyncio.sleep(0)` 能把那条跳过换成通过,但换来的通过验的是这句人为的等待,不是
分发器有没有吞掉取消。
"""
return "工具的输出"
async def _raises(arguments: Mapping[str, object]) -> str:
"""跑得完、但动作本身报错的那个工具。
抛一个普通异常而不是 `ToolEnvironmentError`:后者是「环境坏了」那一档,会被分发器记成
环境故障,而这套用例要的恰恰是「动作报错仍然算已执行」。
"""
raise ValueError("这个工具自己报错了")
@dataclass(frozen=True, slots=True, kw_only=True)
class _ActionSamples:
executes_cleanly: Action
executes_but_errors: Action
class TestRegistryExecutor(ActionExecutorContract):
@pytest.fixture
def action_executor(self) -> RegistryExecutor:
registry = ToolRegistry(
[
ToolSpec(
name="echo",
description="回一段固定文本",
parameters=_NO_ARGUMENTS,
handler=_returns_text,
),
ToolSpec(
name="boom",
description="抛一个普通异常",
parameters=_NO_ARGUMENTS,
handler=_raises,
),
]
)
return registry.executor()
@pytest.fixture
def action_samples(self, records: RecordFactory) -> _ActionSamples:
return _ActionSamples(
executes_cleanly=records.action(text="echo", tool_name="echo"),
executes_but_errors=records.action(text="boom", tool_name="boom"),
)
-236
View File
@@ -1,236 +0,0 @@
"""存储接缝的行为契约。
这份文件是「一次运行的日志到底保证什么」的权威(`CLAUDE.md` §0)。两个已知实现形态差别
很大——一个逐行追加本地文件,一个写关系数据库——所以下面每一条都只说行为,不碰形态。
**行为的理由不在这里。** 崩溃恢复为什么这么设计见 `design/0002`,写入粒度与前缀持久性见
`design/0005`。这里只断言结果。
## 标成 `xfail` 的那两条
它们是**已知没有机器兜底的承诺**,不是还没写的测试。标成会失败的测试而不是写一句注释,是为了
让它们在每次跑套件时都被看见;`strict=True` 是配套的:哪天真的验得了、测试过了,它会以 XPASS
报错,逼人回来把标记连同说明一起删掉。它们不带 fixture,否则会被「实现还没有」那个跳过挡住,
于是「验不了」就伪装成了「还没轮到」。
剩下那条曾经答不上的——没有动作的步 `StepCompleted.result_id` 填什么——已经由 `design/0006`
决策七答掉(可为空,且为空当且仅当动作结果也为空),对应的测试已经改写成真断言。
"""
import pytest
pytestmark = pytest.mark.contract
# --------------------------------------------------------------------------
# 一、写进去的读得回来
# --------------------------------------------------------------------------
async def test_written_intent_is_readable(store, records):
"""写一条意图,读回整份日志时它必须在里面。
这是全套最基本的一条:意图日志的全部意义是「比进程活得久」,写了读不回来,后面每一条
恢复语义都建立在空气上。
"""
intent = records.model_call_intent(run_id="r1", call_index=0, result_id="m0")
await store.write_intent(intent)
log = await store.read_log("r1")
assert intent in log.intents
async def test_log_of_unknown_run_is_empty_not_an_error(store):
"""读一个从没写过的运行标识,得到一份空日志,而不是异常。
`run` 在开工前要判断「这个标识是不是已经有日志了」,靠的就是这一条。如果读不存在的
运行会抛异常,那个判断就得写成捕获异常——而捕获异常来做流程控制,会把真正的存储故障
一起吞掉。
"""
log = await store.read_log("never-written")
assert log.started is None
assert log.intents == ()
assert log.finished is None
async def test_two_runs_do_not_leak_into_each_other(store, records):
"""两个运行标识各写各的,互相看不见对方的记录。
端口不持有「当前运行」的隐式状态,这条测试是那个要求的外部可观测形式。一个有隐式当前
运行的实现会在并发下把 A 的意图写进 B 的日志,而那种错在单线程测试里永远不出现。
"""
a = records.model_call_intent(run_id="run-a", call_index=0, result_id="m0")
b = records.model_call_intent(run_id="run-b", call_index=0, result_id="m0")
await store.write_intent(a)
await store.write_intent(b)
assert (await store.read_log("run-a")).intents == (a,)
assert (await store.read_log("run-b")).intents == (b,)
# --------------------------------------------------------------------------
# 二、四态:恢复靠「意图有没有 / 结果有没有」判定
# --------------------------------------------------------------------------
async def test_intent_without_result_is_readable_as_such(store, records):
"""写了意图、没写结果,读回来必须能看出「这个 ID 没有结果」。
这是四态表里「状态未知」那一档的输入。存储不负责判定,但它必须让判定问得出口——恢复
要按预分配的 ID 精确地问,而不是模糊匹配去猜哪条结果对应哪次执行。
"""
intent = records.action_intent(run_id="r1", call_index=0, result_id="a0")
await store.write_intent(intent)
log = await store.read_log("r1")
assert intent in log.intents
assert all(step.result_id != "a0" for step in log.steps)
async def test_result_without_intent_is_visible_to_the_reader(store, records):
"""只写结果不写意图,读回来必须原样可见,存储自己不许修复也不许拒收。
「有结果没意图」是日志损坏,处置是拒绝续跑——但那个判断归恢复逻辑,不归存储。存储在
这里悄悄补一条意图或者拒绝这次写入,都会让损坏变得不可见,而不可见的损坏会被当成
正常数据继续用下去。
"""
result = records.model_call_result(run_id="r1", result_id="orphan", reply=records.reply())
await store.write_model_call_result(result)
log = await store.read_log("r1")
assert log.model_results == (result,)
assert log.intents == ()
async def test_failed_model_call_is_recorded_as_a_result_not_as_nothing(store, records):
"""模型调用失败也要落一条结果记录,否则恢复会把它读成「状态未知」。
失败这件事是确定的:调用发出去了、失败了、库记了一条步。如果这时不写结果条目,恢复
只看见「意图有、结果无」,走重放策略——而这次调用的状态一点都不未知。下游按停止原因
做的统计会照单收下这个错误。
"""
result = records.model_call_result(run_id="r1", result_id="m0", reply=None, failure="连接超时")
await store.write_model_call_result(result)
(readback,) = (await store.read_log("r1")).model_results
assert readback.reply is None
assert readback.failure == "连接超时"
# --------------------------------------------------------------------------
# 三、原子写
# --------------------------------------------------------------------------
async def test_action_result_and_step_land_together(store, records):
"""动作结果与步记录一次原子落地:读回来要么两者都在,要么都不在。
不原子的话,崩在两者之间会让那一步的历史文本永远丢失,而恢复判定会把它读成「执行完了,
跳过」——恢复出来的消息序列比不中断跑完时少一轮,后面每一步都跟着偏。
**这条测试只能验「一起可见」,验不了「一起不可见」。** 见本文件末尾那条。
"""
step = records.step_completed(
run_id="r1", result_id="a0", action_outcome=records.outcome(), step=records.step(step_idx=0)
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].action_outcome is not None
async def test_step_without_an_action_is_still_recorded(store, records):
"""没有动作的步照样留痕:解析失败、模型调用失败、最终回答三种都算一步。
预算对等要求它们计入步数——它们确实消耗了一次模型调用。丢掉那一步还会丢掉模型在出故障时
说了什么,而那正是排查「环境坏了还是模型写了危险代码」最需要的。
这条曾经写不出来:那时 `StepCompleted.result_id` 是必填字符串,而这一步没写过动作意图、
没有预分配的 ID,随便编一个会让恢复读到一条对不上任何意图的记录,按四态表最后一行判成
日志损坏。`design/0006` 决策七把它改成可为空,并要求**它为空当且仅当动作结果也为空**,
这个洞才补上。存储要能原样存下这个形状。
"""
step = records.step_completed(
run_id="r1", result_id=None, action_outcome=None, step=records.step(step_idx=0)
)
await store.write_step_completed(step)
log = await store.read_log("r1")
assert log.steps == (step,)
assert log.steps[0].result_id is None
assert log.steps[0].action_outcome is None
# --------------------------------------------------------------------------
# 四、运行的开始与结束
# --------------------------------------------------------------------------
async def test_run_finished_is_visible_before_the_result_is_returned(store, records):
"""「这次运行结束了」这个标记由库写下,而且写在把结果交给调用方之前。
另一条路有个具体的失败场景:结果由项目落盘的话,「跑完了、库返回了、项目存的时候崩了」
这种情况下,重启后日志显示最后一步有结果、没有结束标记,而项目那边什么都没有。续跑会
重复执行最后一步的副作用,不续跑就丢掉一次已经花完钱的运行。歧义来自结果跨了两个存储。
"""
finished = records.run_finished(run_id="r1", result=records.result(run_id="r1"))
await store.write_run_finished(finished)
assert (await store.read_log("r1")).finished == finished
async def test_run_started_carries_the_parameter_snapshot(store, records):
"""运行开始记录带着这次的参数快照,续跑时拿它与当前装配比对。
没有它,用同一个运行标识换一份定义续跑,前几步与后几步会来自两个不同的配置而全程零
报错——那正是要到统计阶段才分不清哪些行是真的那类损坏。
"""
started = records.run_started(run_id="r1", parameter_snapshot={"model": "m-1"})
await store.write_run_started(started)
assert (await store.read_log("r1")).started.parameter_snapshot == {"model": "m-1"}
# --------------------------------------------------------------------------
# 五、这套测试**验不了**的两条承诺
# --------------------------------------------------------------------------
@pytest.mark.xfail(reason="已知缺口:这条承诺没有机器兜底", strict=True)
def test_atomicity_under_crash_is_not_checkable_here():
"""原子性的另一半——「崩在中间时两者都不可见」——这一层验不了。
要验它得在写入过程中把进程杀掉,而契约测试跑在一个进程里、面对的是一个已经装配好的
实现,没有位置插入那次崩溃。给端口加一个「故意在这里失败」的钩子能验,但那个钩子会
变成公共 API 的一部分,而它只为测试存在。
结论是这条承诺**没有机器兜底**,只能靠 `CLAUDE.md` §3 那轮对抗审查看实现。把这件事
写成一条会失败的测试而不是一句注释,是为了让它在每次跑套件时都被看见。
"""
pytest.fail(
"已知缺口:原子写的「一起不可见」这一半没有机器检查。"
"落地时要在 stores 的 unit 测试里用可注入的故障点覆盖,"
"并在 design/0005 决策二登记这条契约测试覆盖不到。"
)
@pytest.mark.xfail(reason="已知缺口:这条承诺没有机器兜底", strict=True)
def test_prefix_durability_is_not_checkable_here():
"""前缀持久性同样验不了,理由更硬一层。
它说的是「第 k 次写入被确认持久时,前 k-1 次也已经持久」,而「已经持久」是掉电之后
才看得出来的性质。在一个进程里读得回来,不等于它落了盘。
"""
pytest.fail(
"已知缺口:前缀持久性没有机器检查。两个已知形态天然满足它"
"(同一文件的追加写、同一连接上顺序提交的事务),"
"所以它实际是对实现形态的约束,落地时靠评审看,不靠这套测试。"
)
+36
View File
@@ -0,0 +1,36 @@
"""把存储契约接到库自带的两个实现上。
同一套用例在两种形态上各跑一遍——一个逐行追加进本地文件,一个只留在进程内存里。一条其实
只在其中一种形态下成立的断言在这里当场红;同一条断言写进某一个实现自己的单元测试里,另一个
实现漏掉它不会有任何东西发现。这正是 `research-wiki/design/0014-contract-suite-distribution.md`
决策一把接法从「覆盖同名 fixture」换成「继承基类」换来的:一份契约同时验多个实现。
`contract` 这个标记打在本文件上,不打在套件里。套件随包发到下游,而一个下游开着
`--strict-markers` 又没注册这个 marker 的话,炸掉的是整份文件的收集(决策二第五条)。
"""
from pathlib import Path
import pytest
from polyloop.stores import JsonlRunStore, VolatileRunStore
from polyloop.testing import RunStoreContract
pytestmark = pytest.mark.contract
class TestJsonlRunStore(RunStoreContract):
"""逐行追加进本地文件的那个实现。"""
@pytest.fixture
def store(self, tmp_path: Path) -> JsonlRunStore:
"""`tmp_path` 每条用例一个新目录,套件要的「每次返回一个空存储」自动成立。"""
return JsonlRunStore(directory=tmp_path)
class TestVolatileRunStore(RunStoreContract):
"""只留在进程内存里的那个实现。"""
@pytest.fixture
def store(self) -> VolatileRunStore:
return VolatileRunStore()
@@ -0,0 +1,94 @@
"""把动作执行契约接到一个有挂起点的测试替身上。
同一套用例在两种形态上各跑一遍:`test_registry_executor.py` 接的分发器是纯计算的,取消那条
用例在它身上永远走跳过分支——取消发出去时它已经跑完,没有机会吞掉取消。那条用例的断言半边
因此在本仓库一次都没被执行过,而契约套件的目标是每一条用例都至少被真的执行一次
`research-wiki/design/0014-contract-suite-distribution.md` 决策六)。这个替身补的就是「有挂起
点」那一档。
**不是给分发器塞一句人为的等待。** 那样换来的通过验的是那句等待,不是分发器有没有吞掉取消
`test_registry_executor.py` 里那个工具的 docstring 说的就是这件事)。补一个另外的实现,验的
是真有等待点时取消穿不穿得过去。
**这个替身住在 `tests/` 里,不进 wheel,任何下游都拿不到它。** 「库不带默认实现」那条禁的是
`src/` 下出现一个能用的实现——下游装了包就拿得到,就会有人直接用,于是动作语言被库替它定了。
判据是下游拿不拿得到,不是代码库里有没有一个能跑的实现(同上,决策六)。
`contract` 这个标记打在本文件上,不打在套件里,理由见 `test_run_stores.py`。
"""
import asyncio
from collections.abc import Mapping
from dataclasses import dataclass
import pytest
from polyloop.ports import Action
from polyloop.testing import ActionExecutorContract, RecordFactory
from polyloop.types import ActionOutcome, ActionStatus
pytestmark = pytest.mark.contract
#: 这个替身认得的全部动作语言:正文以它开头的那个动作,跑完之后自己报错。
_FAILS_PREFIX = "FAIL "
#: 挂起窗口的长度。**不能用 `asyncio.sleep(0)`**:那只是让出一次,取消赶不赶得上就取决于事件
#: 循环就绪队列里两个回调的先后,而那个顺序不是承诺。给一个真的定时器,套件让出一次之后这个
#: 替身一定还没跑完,`cancel()` 一定返回 `True`,取消那条用例才稳定地走到断言那一半。
_PAUSE_SECONDS = 0.001
class _SuspendingActionExecutor:
"""每个动作都先真的挂起一小段,再返回一个「已执行」的结果。
那次挂起模拟的是真实实现里的等待点——网络往返、子进程、容器会话。**它不捕获任何异常**,
所以挂起期间收到的 `CancelledError` 原样穿出去,这正是契约要断言的行为
`CLAUDE.md` §1.6)。没有 in-flight 资源要放,所以也没有 `finally`。
它满足 `polyloop.ports.ActionExecutor`,但不显式继承那个 Protocol:结构化子类型不需要继承。
"""
async def execute(self, action: Action) -> ActionOutcome:
await asyncio.sleep(_PAUSE_SECONDS)
if action.text.startswith(_FAILS_PREFIX):
return ActionOutcome(
status=ActionStatus.EXECUTED,
observation=f"动作自己报错了:{action.text[len(_FAILS_PREFIX) :]}",
observation_is_synthetic=False,
env_reported_completion=False,
observation_truncated_chars=0,
)
return ActionOutcome(
status=ActionStatus.EXECUTED,
observation=f"动作的输出:{action.text}",
observation_is_synthetic=False,
env_reported_completion=False,
observation_truncated_chars=0,
)
def parameters(self) -> Mapping[str, str]:
return {"kind": "suspending", "pause_seconds": str(_PAUSE_SECONDS)}
@dataclass(frozen=True, slots=True, kw_only=True)
class _ActionSamples:
executes_cleanly: Action
executes_but_errors: Action
class TestSuspendingActionExecutor(ActionExecutorContract):
@pytest.fixture
def action_executor(self) -> _SuspendingActionExecutor:
return _SuspendingActionExecutor()
@pytest.fixture
def action_samples(self, records: RecordFactory) -> _ActionSamples:
"""两个样本的状态都是「已执行」,动作本身报错的那个也是。
这个替身的动作语言里没有「没进执行」和「环境坏了」这两档,报错的动作照样跑完了,只是
观察里多一句错误说明——套件对样本的要求就是这个(`action_samples` 的退化情况那一段)。
"""
return _ActionSamples(
executes_cleanly=records.action(text="做点事"),
executes_but_errors=records.action(text=f"{_FAILS_PREFIX}除以零"),
)
@@ -9,6 +9,7 @@
""" """
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass
import pytest import pytest
@@ -21,6 +22,7 @@ from polygateway.errors import AllSourcesExhausted # noqa: E402
from polyloop.adapters import GatewayModelClient # noqa: E402 from polyloop.adapters import GatewayModelClient # noqa: E402
from polyloop.ports import ModelCall # noqa: E402 from polyloop.ports import ModelCall # noqa: E402
from polyloop.testing import ModelClientContract # noqa: E402
from polyloop.types import Message, Role, TextBlock # noqa: E402 from polyloop.types import Message, Role, TextBlock # noqa: E402
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -207,3 +209,42 @@ def test_changing_a_sampling_parameter_changes_the_parameters() -> None:
).parameters() ).parameters()
assert before["sources"] != after["sources"] assert before["sources"] != after["sources"]
# ---------------------------------------------------------------------------
# 模型调用契约(`polyloop.testing.ModelClientContract`)接在这一层,不在契约层。
#
# 分层判据是「依赖什么」(`CLAUDE.md` §1.9):这里的配置、装配守卫、响应类型全是网关真的
# 那套,所以它是 integration。`research-wiki/design/0014-contract-suite-distribution.md`
# 决策六那张表里,五个接缝只有这一行落在契约层之外,就是这个原因。
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True, kw_only=True)
class _UnsupportedBlock:
"""一种适配器不认得的内容块。
它是 `failing_call` 用来让适配器在入参这一关就挂掉的东西。适配器把每个块翻译成文本,
碰到不认得的类型直接抛——那是它自己的守卫,不是替身编出来的失败。
"""
class TestGatewayModelClient(ModelClientContract):
"""网关适配器要满足模型调用接缝的全部契约。"""
@pytest.fixture
def model_client(self) -> GatewayModelClient:
return GatewayModelClient(client=_StubClient(_response()), settings=_settings())
@pytest.fixture
def failing_call(self) -> ModelCall:
"""一次带着适配器不认得的内容块的调用。
**失败发生在请求打出去之前**:适配器逐块翻译消息,碰到不是文本块的东西直接抛。所以这
条路径不碰替身客户端、不产生任何副作用,客户端实例失败之后照样能接着服务——这三件事
正是 `failing_call` 那份 docstring 要求的。
另一条路是让替身客户端认一个暗号、见到就抛,那等于在实现这一侧重新造出套件刚刚扔掉的
那个约定,而它验的会变成替身的分支写对没有。
"""
return _call(messages=(Message(role=Role.USER, content=(_UnsupportedBlock(),)),))
+21
View File
@@ -49,6 +49,27 @@ def test_importing_polyloop_does_not_import_polygateway() -> None:
assert result.stdout.strip() == "False", result.stdout assert result.stdout.strip() == "False", result.stdout
def test_importing_polyloop_does_not_import_pytest() -> None:
"""`import polyloop` 之后 `sys.modules` 里不许出现 `pytest`。
契约套件 `polyloop.testing` 顶层就 import pytest,而顶层包不 re-export 它——它和
`stores`、`adapters` 同一档,必须显式 import。少了这条断言,哪天有人顺手把 `testing`
加进 `polyloop/__init__.py`,每个下游的运行时就都被拽上一个 pytest 依赖,而 pytest 在
生产环境里通常根本没装,表现是下游一 import 本库就 `ModuleNotFoundError`。
和上面那条 polygateway 同构,也同样在子进程里跑:本进程早就 import 过 pytest 了。
"""
code = "import polyloop, sys; print('pytest' in sys.modules)"
result = subprocess.run( # noqa: S603
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=True,
cwd=REPO_ROOT,
)
assert result.stdout.strip() == "False", result.stdout
def test_py_typed_marker_ships_with_the_package() -> None: def test_py_typed_marker_ships_with_the_package() -> None:
"""`py.typed` 必须在包根里。 """`py.typed` 必须在包根里。
+177
View File
@@ -0,0 +1,177 @@
"""记录工厂造出来的东西必须合法:构造得出,而且能过一遍编解码往返。
**它守的是工厂造的记录合不合法,不是「契约用例引用的方法存不存在」。** 后者只有真的执行那条
用例才查得出——一条用例调了工厂上不存在的方法,工厂自己的单元测试怎么写都看不见它,因为那
条引用根本不在这个文件里。所以这份测试全绿不代表契约套件接上了实现;把五套契约都接到实现上
是另一件事,做在 `tests/contract/` 与 `tests/integration/`
`research-wiki/design/0014-contract-suite-distribution.md` 决策六)。
往返用的是 `polyloop.serialization`,因为那是记录进日志的唯一通道:一条编不出来或者解回来
不等于自己的记录,在契约套件里表现成某个存储实现的用例红,而红的原因其实在工厂这一侧。
"""
import pytest
from polyloop.serialization import (
decode_intent,
decode_model_call_result,
decode_run_finished,
decode_run_result,
decode_run_started,
decode_step_completed,
decode_step_record,
encode,
)
from polyloop.testing import RecordFactory
from polyloop.types import ActionStatus, ReplayPolicy, StopReason
pytestmark = pytest.mark.unit
@pytest.fixture
def records() -> RecordFactory:
return RecordFactory()
def test_run_started_round_trips(records: RecordFactory) -> None:
record = records.run_started(run_id="r1", parameter_snapshot={"model": "m-1"})
assert decode_run_started(encode(record)) == record
def test_model_call_intent_round_trips(records: RecordFactory) -> None:
record = records.model_call_intent(run_id="r1", call_index=0, result_id="m0")
assert decode_intent(encode(record)) == record
def test_action_intent_round_trips_with_a_non_default_replay_policy(
records: RecordFactory,
) -> None:
"""重放策略跟着走一遍。它是恢复时「这一步要不要重跑」的输入,编码里丢了就会静默降级。"""
record = records.action_intent(
run_id="r1", call_index=1, result_id="a0", replay_policy=ReplayPolicy.SAFE
)
assert decode_intent(encode(record)) == record
assert record.replay_policy is ReplayPolicy.SAFE
def test_successful_model_call_result_round_trips(records: RecordFactory) -> None:
record = records.model_call_result(run_id="r1", result_id="m0", reply=records.reply())
assert decode_model_call_result(encode(record)) == record
def test_failed_model_call_result_round_trips(records: RecordFactory) -> None:
"""失败那一档单独走一遍:回复为空、失败说明有值,两个可空字段的组合和成功那档相反。"""
record = records.model_call_result(run_id="r1", result_id="m0", failure="连接超时")
decoded = decode_model_call_result(encode(record))
assert decoded == record
assert decoded.reply is None
assert decoded.failure == "连接超时"
def test_step_record_round_trips(records: RecordFactory) -> None:
record = records.step(step_idx=3)
assert decode_step_record(encode(record)) == record
def test_unparsed_step_record_round_trips(records: RecordFactory) -> None:
"""解析失败那一档:动作为空、解析说明有值。"""
record = records.step(parse_ok=False)
decoded = decode_step_record(encode(record))
assert decoded == record
assert decoded.action is None
assert decoded.parse_error is not None
def test_step_completed_round_trips(records: RecordFactory) -> None:
record = records.step_completed(
run_id="r1",
result_id="a0",
action_outcome=records.outcome(),
step=records.step(),
)
assert decode_step_completed(encode(record)) == record
def test_step_completed_without_an_action_round_trips(records: RecordFactory) -> None:
"""没有动作的那一步:结果标识与动作结果同时为空,这个组合存储要能原样存下来。"""
record = records.step_completed(
run_id="r1", result_id=None, action_outcome=None, step=records.step()
)
decoded = decode_step_completed(encode(record))
assert decoded == record
assert decoded.result_id is None
assert decoded.action_outcome is None
def test_outcome_carries_a_non_default_status(records: RecordFactory) -> None:
"""状态取值跟着记录走一遍。默认那档和显式传的那档在编码里长得一样,各验一次。"""
record = records.step_completed(
run_id="r1",
result_id="a0",
action_outcome=records.outcome(status=ActionStatus.ENV_ERROR),
step=records.step(),
)
decoded = decode_step_completed(encode(record))
assert decoded == record
assert decoded.action_outcome is not None
assert decoded.action_outcome.status is ActionStatus.ENV_ERROR
def test_run_result_round_trips(records: RecordFactory) -> None:
record = records.result(run_id="r1", stop_reason=StopReason.STEP_BUDGET)
assert decode_run_result(encode(record)) == record
def test_run_finished_round_trips(records: RecordFactory) -> None:
record = records.run_finished(run_id="r1", result=records.result(run_id="r1"))
assert decode_run_finished(encode(record)) == record
def test_model_call_defaults_to_one_user_message(records: RecordFactory) -> None:
"""一次模型调用不是记录,编解码不管它,但它的默认消息序列是契约用例的隐式输入。
默认给一条用户消息而不是空序列:一个真实的实现拿到空消息序列多半直接拒绝,于是那几条
用例验的就变成了它的入参校验,不是它的返回结构。
"""
call = records.model_call(call_index=0, result_id="m0")
assert len(call.messages) == 1
assert call.messages[0].content[0].text != ""
def test_action_with_a_tool_name_carries_a_tool_call(records: RecordFactory) -> None:
"""带工具名的动作要真的带上工具调用,按工具名分发的执行器靠它才走得进分发。"""
action = records.action(text="echo", tool_name="echo")
assert action.tool_call is not None
assert action.tool_call.name == "echo"
def test_action_without_a_tool_name_carries_no_tool_call(records: RecordFactory) -> None:
"""不带工具名的那种是代码执行型动作,工具调用必须为空,否则会被分发器当成工具调用收下。"""
assert records.action().tool_call is None
def test_event_carries_the_step_it_reports(records: RecordFactory) -> None:
"""事件不是记录,但它带着的那条步记录要和工厂造的其他步记录同形。"""
event = records.event(step_idx=2)
assert event.step is not None
assert event.step.step_idx == 2
assert decode_step_record(encode(event.step)) == event.step