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:
@@ -1,228 +0,0 @@
|
||||
"""契约套件的装配点。
|
||||
|
||||
这套测试**不针对任何具体实现**。它写的是「不管你怎么实现,都必须满足这些行为」,所以它
|
||||
自己不造实现,只声明「你得提供什么」。任何一个下游写完自己的存储或适配器,把它接到这里
|
||||
的 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.types import (
|
||||
ActionOutcome,
|
||||
ActionStatus,
|
||||
Intent,
|
||||
IntentKind,
|
||||
ModelCallResult,
|
||||
ModelReply,
|
||||
ReplayPolicy,
|
||||
RunFinished,
|
||||
RunResult,
|
||||
RunStarted,
|
||||
StepCompleted,
|
||||
StepRecord,
|
||||
StopReason,
|
||||
)
|
||||
|
||||
#: 还没有默认实现的那几个接缝,套件里对应的测试全部跳过。
|
||||
_NO_IMPLEMENTATION = (
|
||||
"库不带这个接缝的默认实现——带了就等于替某一家定了它的协议。"
|
||||
"下游在自己的 conftest.py 里覆盖这个 fixture,把自己的实现接进来跑。"
|
||||
)
|
||||
|
||||
|
||||
class _Records:
|
||||
"""构造各类记录的工厂。
|
||||
|
||||
它不是被测对象,是让测试正文读得懂的一层薄封装:`records.model_call_intent(...)` 比直接
|
||||
写一长串构造参数更能看出这条测试在断言什么。工厂随套件一起走,因为记录类的字段是库的
|
||||
公共承诺,下游不该为了跑契约测试去手写构造。
|
||||
"""
|
||||
|
||||
#: 让测试写 `records.action_status.EXECUTED` 而不必自己 import 那个枚举。
|
||||
action_status = ActionStatus
|
||||
|
||||
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_intent(self, *, run_id: str, call_index: int, result_id: str) -> Intent:
|
||||
return Intent(
|
||||
run_id=run_id,
|
||||
kind=IntentKind.MODEL_CALL,
|
||||
call_index=call_index,
|
||||
result_id=result_id,
|
||||
replay_policy=ReplayPolicy.NEVER,
|
||||
)
|
||||
|
||||
def action_intent(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
call_index: int,
|
||||
result_id: str,
|
||||
replay_policy: ReplayPolicy = ReplayPolicy.NEVER,
|
||||
) -> Intent:
|
||||
return Intent(
|
||||
run_id=run_id,
|
||||
kind=IntentKind.ACTION,
|
||||
call_index=call_index,
|
||||
result_id=result_id,
|
||||
replay_policy=replay_policy,
|
||||
)
|
||||
|
||||
def model_call_result(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
result_id: str,
|
||||
reply: ModelReply | None = None,
|
||||
failure: str | None = None,
|
||||
) -> ModelCallResult:
|
||||
"""`reply` 与 `failure` 恰好一个有值,两个都不给时默认造一条成功的。"""
|
||||
if reply is None and failure is None:
|
||||
reply = self.reply()
|
||||
return ModelCallResult(run_id=run_id, result_id=result_id, reply=reply, failure=failure)
|
||||
|
||||
def outcome(
|
||||
self,
|
||||
*,
|
||||
status: ActionStatus = ActionStatus.EXECUTED,
|
||||
observation: str = "环境的输出",
|
||||
env_reported_completion: bool = False,
|
||||
) -> ActionOutcome:
|
||||
return ActionOutcome(
|
||||
status=status,
|
||||
observation=observation,
|
||||
observation_is_synthetic=False,
|
||||
env_reported_completion=env_reported_completion,
|
||||
observation_truncated_chars=0,
|
||||
)
|
||||
|
||||
def step(self, *, step_idx: int = 0, parse_ok: bool = True) -> StepRecord:
|
||||
return StepRecord(
|
||||
step_idx=step_idx,
|
||||
raw_output="模型说的话",
|
||||
content_chars=5,
|
||||
thinking_chars=0,
|
||||
action="做点事" if parse_ok else None,
|
||||
parse_ok=parse_ok,
|
||||
parse_error=None if parse_ok else "解释不出动作",
|
||||
observation="环境的输出",
|
||||
observation_is_synthetic=False,
|
||||
observation_truncated_chars=0,
|
||||
prompt_chars=100,
|
||||
call_id="call-1",
|
||||
step_wall_ms=12,
|
||||
)
|
||||
|
||||
def step_completed(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
result_id: str | None,
|
||||
action_outcome: ActionOutcome | None,
|
||||
step: StepRecord,
|
||||
) -> StepCompleted:
|
||||
return StepCompleted(
|
||||
run_id=run_id, result_id=result_id, action_outcome=action_outcome, step=step
|
||||
)
|
||||
|
||||
def run_started(self, *, run_id: str, parameter_snapshot: Mapping[str, str]) -> RunStarted:
|
||||
return RunStarted(run_id=run_id, parameter_snapshot=dict(parameter_snapshot))
|
||||
|
||||
def result(
|
||||
self, *, run_id: str, stop_reason: StopReason = StopReason.TASK_COMPLETED
|
||||
) -> RunResult:
|
||||
return RunResult(
|
||||
run_id=run_id, stop_reason=stop_reason, final_answer="42", steps=(self.step(),)
|
||||
)
|
||||
|
||||
def run_finished(self, *, run_id: str, result: RunResult) -> RunFinished:
|
||||
return RunFinished(run_id=run_id, result=result)
|
||||
|
||||
def action(self, *, text: str = "做点事", tool_name: str | None = None) -> Action:
|
||||
return Action(
|
||||
text=text,
|
||||
tool_call=None if tool_name is None else ToolCall(name=tool_name, arguments={}),
|
||||
)
|
||||
|
||||
def event(self, *, run_id: str = "run-1", step_idx: int = 0) -> Event:
|
||||
return Event(
|
||||
kind=EventKind.STEP_FINISHED,
|
||||
run_id=run_id,
|
||||
model_binding={"item": "a"},
|
||||
step=self.step(step_idx=step_idx),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def records() -> _Records:
|
||||
return _Records()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path) -> JsonlRunStore:
|
||||
"""被测的存储接缝实现。
|
||||
|
||||
默认接的是库自带的那个逐行追加实现。下游覆盖这个 fixture,返回自己的实例。
|
||||
|
||||
每次调用返回一个**空的**存储:套件里每条测试都假设自己面对一份干净的日志,共用状态会让
|
||||
测试之间的顺序变成隐式依赖。`tmp_path` 每条测试一个新目录,这一条自动成立。
|
||||
"""
|
||||
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)
|
||||
@@ -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`,不在这个接缝的
|
||||
契约里。
|
||||
"""
|
||||
@@ -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="我先想想。"),
|
||||
)
|
||||
@@ -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`,因为要跑完一次完整运行再把日志读
|
||||
回来,而这个接缝的契约只看得见一个出口实现。
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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"})
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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(
|
||||
"已知缺口:前缀持久性没有机器检查。两个已知形态天然满足它"
|
||||
"(同一文件的追加写、同一连接上顺序提交的事务),"
|
||||
"所以它实际是对实现形态的约束,落地时靠评审看,不靠这套测试。"
|
||||
)
|
||||
@@ -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 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(),)),))
|
||||
|
||||
@@ -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` 必须在包根里。
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user