test(contract): 落成五个接缝的契约骨架,用它逼出七个设计洞
第 ④ 阶段的核心交付物。这套测试不针对任何具体实现,写的是「不管你怎么实现 都必须满足这些行为」,下游写完自己的实现接到 fixture 上跑一遍即可, 是任何新适配器的准入标准(CLAUDE.md §0)。 现在全部跳过,因为公共类型与 Protocol 还没落地。价值不在跑,在写: 写一条契约要求把每次调用逐字写出来——方法叫什么、参数填什么、返回值怎么取, 而散文里读着通顺的地方,落到这一步就露出来了。 三轮文档评审没报出的七个洞,写这套测试时全部撞了出来: 没有动作的步 result_id 填什么;三个 ActionStatus 取值的触发条件; 动作被拒绝时观察的来源(执行器与 SyntheticObservations 两处都有); 解释器能不能抛异常;Event 没有字段所以事件出口的契约只写得出一半; read_log 读不存在的运行必须返回空日志而不是抛异常; 以及原子性与前缀持久性这两条 0005 的承诺根本没有机器兜底—— 写这套测试之前我们默认它们会被契约测试接住。 洞标成 xfail(strict=True) 而不是常驻 fail:一个永远红的套件会训练所有人忽略红。 它们都不带 fixture,否则会被「实现还没有」那个跳过挡住, 于是「答不上来」就伪装成了「还没轮到」。补上之后 XPASS 会报错,逼人回来删标记。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""模型调用接缝的行为契约。
|
||||
|
||||
**这一层不打真实网关**——那是 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"})
|
||||
Reference in New Issue
Block a user