diff --git a/pyproject.toml b/pyproject.toml index a5c0ba5..d3cd1db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,11 @@ dependencies = [] # 模型适配器单独成一个 extra:不用它的人不该被迫装上网关,也不该在 import 时把网关连同 # 它的 provider 目录一起拉起来(依赖规则 9)。PolyGateway 不在公共 PyPI 上,装它要配私有源。 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、 # 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" 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] where = ["src"] @@ -94,13 +106,14 @@ markers = [ addopts = "--strict-markers --import-mode=importlib -m 'not e2e'" # --------------------------------------------------------------------------- -# 依赖规则的机器形式。九条规则本身与它们各自的理由在 +# 依赖规则的机器形式。十条规则本身与它们各自的理由在 # research-wiki/design/0003-public-api-shape.md 决策八,当前形状在 # research-wiki/explanation/architecture.md 第七节。这里只写契约,不复述理由。 # -# 九条里有两条写不成 import-linter 契约,它们是 tests/ 里的测试: +# 十条里有两条写不成 import-linter 契约,还有一条只有前半写得成;写不成的那些是 tests/ 里的测试: # 规则 6(types 与 ports 禁止 import 任何第三方包)——「任何第三方」不是一份可枚举的清单。 # 规则 9(import polyloop 之后 sys.modules 里没有 polygateway)——那是运行时事实,不是静态图。 +# 规则 10 的后半(import polyloop 之后 sys.modules 里没有 pytest)——同上。前半在下面。 # --------------------------------------------------------------------------- [tool.importlinter] root_packages = ["polyloop"] @@ -115,7 +128,7 @@ include_external_packages = true name = "分层:装配层 > 逻辑层 > ports > types,且同层互不 import" type = "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.ports", "polyloop.types", @@ -131,6 +144,7 @@ forbidden_modules = [ "polyloop.session", "polyloop.stores", "polyloop.adapters", + "polyloop.testing", "polyloop.tools", "polyloop._assembly", "polyloop._stopping", @@ -153,6 +167,7 @@ type = "forbidden" source_modules = [ "polyloop.session", "polyloop.stores", + "polyloop.testing", "polyloop.tools", "polyloop._assembly", "polyloop._stopping", @@ -163,6 +178,26 @@ source_modules = [ ] 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 都能绕过它, # 而 open() 是内置函数,import-linter 根本看不见。它拦得住最常见的那种偷懒 # (写着写着顺手 await 一下存储、顺手读个文件),拦不住存心的。 diff --git a/src/polyloop/testing/__init__.py b/src/polyloop/testing/__init__.py new file mode 100644 index 0000000..2c55a35 --- /dev/null +++ b/src/polyloop/testing/__init__.py @@ -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", +] diff --git a/src/polyloop/testing/_action_executor.py b/src/polyloop/testing/_action_executor.py new file mode 100644 index 0000000..78e4c8d --- /dev/null +++ b/src/polyloop/testing/_action_executor.py @@ -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 就是合规的," + "「库替换了它」由库自己的测试守着。" + ) diff --git a/src/polyloop/testing/_decision_parser.py b/src/polyloop/testing/_decision_parser.py new file mode 100644 index 0000000..a3c56e4 --- /dev/null +++ b/src/polyloop/testing/_decision_parser.py @@ -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 != "" diff --git a/src/polyloop/testing/_event_sink.py b/src/polyloop/testing/_event_sink.py new file mode 100644 index 0000000..fadbf18 --- /dev/null +++ b/src/polyloop/testing/_event_sink.py @@ -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),不要指望事件里带着这两份文本。" + ) diff --git a/src/polyloop/testing/_model_client.py b/src/polyloop/testing/_model_client.py new file mode 100644 index 0000000..ce667c5 --- /dev/null +++ b/src/polyloop/testing/_model_client.py @@ -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"}) diff --git a/src/polyloop/testing/_plugin.py b/src/polyloop/testing/_plugin.py new file mode 100644 index 0000000..cdc5a67 --- /dev/null +++ b/src/polyloop/testing/_plugin.py @@ -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 加载插件之前就按发行包做完了,这里没有要补的事。""" diff --git a/tests/contract/conftest.py b/src/polyloop/testing/_records.py similarity index 56% rename from tests/contract/conftest.py rename to src/polyloop/testing/_records.py index 35d02e1..b2f1ad4 100644 --- a/tests/contract/conftest.py +++ b/src/polyloop/testing/_records.py @@ -1,46 +1,31 @@ -"""契约套件的装配点。 - -这套测试**不针对任何具体实现**。它写的是「不管你怎么实现,都必须满足这些行为」,所以它 -自己不造实现,只声明「你得提供什么」。任何一个下游写完自己的存储或适配器,把它接到这里 -的 fixture 上跑一遍,全绿就算合格——这是 `CLAUDE.md` §0 说的「任何新适配器的准入标准」。 - -**接法**:下游在自己的 `conftest.py` 里覆盖同名 fixture,返回自己的实现。 - -**为什么在实现之前就写它**:写一条契约测试要求把每一次调用逐字写出来——方法叫什么、参数 -填什么、返回值怎么取。散文里读着通顺的地方,落到这一步就会露出来。前三轮文档评审抓不到的 -洞,几乎全是这么冒出来的。 -""" +"""构造各类记录的工厂,以及把它递给用例的那个共同父类。""" from collections.abc import Mapping import pytest -from polyloop.ports import Action, Event, EventKind, ToolCall -from polyloop.stores import JsonlRunStore +from polyloop.ports import Action, Event, EventKind, ModelCall, ToolCall from polyloop.types import ( ActionOutcome, ActionStatus, Intent, IntentKind, + Message, ModelCallResult, ModelReply, ReplayPolicy, + Role, RunFinished, RunResult, RunStarted, StepCompleted, StepRecord, StopReason, -) - -#: 还没有默认实现的那几个接缝,套件里对应的测试全部跳过。 -_NO_IMPLEMENTATION = ( - "库不带这个接缝的默认实现——带了就等于替某一家定了它的协议。" - "下游在自己的 conftest.py 里覆盖这个 fixture,把自己的实现接进来跑。" + TextBlock, ) -class _Records: +class RecordFactory: """构造各类记录的工厂。 它不是被测对象,是让测试正文读得懂的一层薄封装:`records.model_call_intent(...)` 比直接 @@ -54,6 +39,36 @@ class _Records: def reply(self, *, call_id: str | None = "call-1", content: str = "hi") -> ModelReply: 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: return Intent( run_id=run_id, @@ -164,65 +179,15 @@ class _Records: ) -@pytest.fixture -def records() -> _Records: - return _Records() +class ContractBase: + """五个契约基类的共同父类,只为把记录工厂递给用例。 - -@pytest.fixture -def store(tmp_path) -> JsonlRunStore: - """被测的存储接缝实现。 - - 默认接的是库自带的那个逐行追加实现。下游覆盖这个 fixture,返回自己的实例。 - - 每次调用返回一个**空的**存储:套件里每条测试都假设自己面对一份干净的日志,共用状态会让 - 测试之间的顺序变成隐式依赖。`tmp_path` 每条测试一个新目录,这一条自动成立。 + 它不是 `research-wiki/design/0014-contract-suite-distribution.md` 决策二第七条否掉的那种 + fixture mixin——那条否的是让下游在自己的子类上多继承一个 fixture 类。这个父类下游既看不见 + 也用不着:`records` 由套件自己提供,没有哪个实现需要覆盖它。 """ - return JsonlRunStore(directory=tmp_path) - -@pytest.fixture -def samples(): - """被测解释器认得的几段模型输出,由实现方提供。 - - **套件不许自己写死输入。** 库不带默认解释器实现,也就不认识任何一家的动作语言:拿一家的 - 代码围栏去喂另一家的 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) + @pytest.fixture + def records(self) -> RecordFactory: + """记录工厂。套件自己提供,不需要在子类里覆盖。""" + return RecordFactory() diff --git a/src/polyloop/testing/_run_store.py b/src/polyloop/testing/_run_store.py new file mode 100644 index 0000000..fefb8ea --- /dev/null +++ b/src/polyloop/testing/_run_store.py @@ -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 次也已经持久。" + "这一层验不了——「已经持久」是掉电之后才看得出来的性质," + "在一个进程里读得回来不等于它落了盘。" + "自己验:它实际是对实现形态的约束(同一文件的追加写、同一连接上顺序提交的事务" + "天然满足它),靠评审看你的实现属不属于这种形态。" + ) diff --git a/tests/contract/test_action_executor.py b/tests/contract/test_action_executor.py deleted file mode 100644 index 6d6e136..0000000 --- a/tests/contract/test_action_executor.py +++ /dev/null @@ -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`,不在这个接缝的 - 契约里。 - """ diff --git a/tests/contract/test_decision_parser.py b/tests/contract/test_decision_parser.py deleted file mode 100644 index cf8453f..0000000 --- a/tests/contract/test_decision_parser.py +++ /dev/null @@ -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 != "" diff --git a/tests/contract/test_decision_parser_double.py b/tests/contract/test_decision_parser_double.py new file mode 100644 index 0000000..6dfc5d1 --- /dev/null +++ b/tests/contract/test_decision_parser_double.py @@ -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="我先想想。"), + ) diff --git a/tests/contract/test_event_sink.py b/tests/contract/test_event_sink.py deleted file mode 100644 index 4623b40..0000000 --- a/tests/contract/test_event_sink.py +++ /dev/null @@ -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`,因为要跑完一次完整运行再把日志读 - 回来,而这个接缝的契约只看得见一个出口实现。 - """ diff --git a/tests/contract/test_event_sink_double.py b/tests/contract/test_event_sink_double.py new file mode 100644 index 0000000..d4c2448 --- /dev/null +++ b/tests/contract/test_event_sink_double.py @@ -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() diff --git a/tests/contract/test_model_client.py b/tests/contract/test_model_client.py deleted file mode 100644 index 3d864d3..0000000 --- a/tests/contract/test_model_client.py +++ /dev/null @@ -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"}) diff --git a/tests/contract/test_registry_executor.py b/tests/contract/test_registry_executor.py new file mode 100644 index 0000000..0c171ce --- /dev/null +++ b/tests/contract/test_registry_executor.py @@ -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"), + ) diff --git a/tests/contract/test_run_store.py b/tests/contract/test_run_store.py deleted file mode 100644 index 0287e2c..0000000 --- a/tests/contract/test_run_store.py +++ /dev/null @@ -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( - "已知缺口:前缀持久性没有机器检查。两个已知形态天然满足它" - "(同一文件的追加写、同一连接上顺序提交的事务)," - "所以它实际是对实现形态的约束,落地时靠评审看,不靠这套测试。" - ) diff --git a/tests/contract/test_run_stores.py b/tests/contract/test_run_stores.py new file mode 100644 index 0000000..fd4b6dc --- /dev/null +++ b/tests/contract/test_run_stores.py @@ -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() diff --git a/tests/contract/test_suspending_executor_double.py b/tests/contract/test_suspending_executor_double.py new file mode 100644 index 0000000..567c688 --- /dev/null +++ b/tests/contract/test_suspending_executor_double.py @@ -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}除以零"), + ) diff --git a/tests/integration/test_gateway_model_client.py b/tests/integration/test_gateway_model_client.py index baa0693..c26f509 100644 --- a/tests/integration/test_gateway_model_client.py +++ b/tests/integration/test_gateway_model_client.py @@ -9,6 +9,7 @@ """ from collections.abc import Mapping +from dataclasses import dataclass import pytest @@ -21,6 +22,7 @@ from polygateway.errors import AllSourcesExhausted # noqa: E402 from polyloop.adapters import GatewayModelClient # 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 pytestmark = pytest.mark.integration @@ -207,3 +209,42 @@ def test_changing_a_sampling_parameter_changes_the_parameters() -> None: ).parameters() 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(),)),)) diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 9dda813..f7a61be 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -49,6 +49,27 @@ def test_importing_polyloop_does_not_import_polygateway() -> None: 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: """`py.typed` 必须在包根里。 diff --git a/tests/unit/test_record_factory.py b/tests/unit/test_record_factory.py new file mode 100644 index 0000000..0aa60fa --- /dev/null +++ b/tests/unit/test_record_factory.py @@ -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