"""动作执行接缝的行为契约。 两个已知形态差别很大:一个把一段代码交给已经开好的容器会话、状态恒为「已执行」,一个查 工具注册表分发、工具不存在或参数不合法时返回「未执行」。下面每一条都要对两者同时成立。 ## 写这套用例时撞出来的两个问题,`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 就是合规的," "「库替换了它」由库自己的测试守着。" )