本系列基于 mini-SWE-agent 源码学习整理;源码版本固定为 Asuhe404/mini-swe-agent@388da74,便于长期核对实现。
系列导航
返回路线图 · 上一篇:07. 配置、工厂与交互子类:从 CLI 参数装配出可控 Agent
适合:零 Python 基础、已有 JavaScript 基础。
重点文件:
tests/agents/test_default.pytests/environments/test_local.pytests/models/test_test_models.py
本节目标:关闭源码后,能够读懂 pytest 的 fixture、参数化、异常与日志断言;能够判断一个失败更可能来自 Model、Environment 还是 Agent;能够先用测试定义一个新 Environment 的合同,再把它接入 DefaultAgent,且全程不调用模型 API。
1. 先用 JavaScript 建立心智模型
1.1 pytest 最像 Vitest/Jest,但 fixture 是按参数名注入
JavaScript/Vitest:
describe.each([
["text", makeTextModel],
["toolcall", makeToolcallModel],
["response_api", makeResponseApiModel],
])("DefaultAgent with %s", (_, makeModel) => {
test("completes", () => {
const model = makeModel(preRecordedOutputs);
const env = new LocalEnvironment();
const agent = new DefaultAgent({ model, env, ...config });
expect(agent.run("task")).toEqual({
exit_status: "Submitted",
submission: "done\n",
});
expect(agent.nCalls).toBe(2);
});
});
Python/pytest 的同一思路:
@pytest.fixture(params=["text", "toolcall", "response_api"])
def model_factory(request, default_config, toolcall_config):
...
def test_successful_completion(model_factory):
factory, config = model_factory
agent = DefaultAgent(model=factory(...), env=LocalEnvironment(), **config)
assert agent.run("task")["exit_status"] == "Submitted"
assert agent.n_calls == 2
关键差别:测试函数没有手动调用 model_factory()。pytest 看到参数名 model_factory,查找同名 fixture,执行它,再把结果传进测试。
1.2 assert 就是 pytest 的 expect
| pytest | Vitest/Jest 类比 |
|---|---|
assert value == expected | expect(value).toEqual(expected) |
assert "x" in text | expect(text).toContain("x") |
assert isinstance(x, float) | expect(typeof x).toBe("number") |
with pytest.raises(Error): fn() | expect(() => fn()).toThrow(Error) |
caplog | 截获 logger 输出的测试工具 |
@pytest.mark.parametrize(...) | test.each(...) |
@pytest.mark.skipif(...) | test.skipIf(...) |
Python 原生 assert 只表达布尔条件;pytest 会重写 assert,失败时展示左右值和差异,因此不需要每次手写错误消息。
1.3 DeterministicModel 是“磁带播放器”,不是 mock API
class TapeModel {
constructor(outputs) {
this.outputs = outputs;
this.index = -1;
}
query(_messages) {
this.index += 1;
return this.outputs[this.index];
}
}
它有三个重要特征:
- 不联网。
- 不根据输入推理。
- 第 N 次 query 固定返回第 N 条预录消息。
所以测试可以精确安排:第一轮观察、第二轮修复、第三轮提交。失败时不受模型随机性、API key、限流或费用影响。
1.4 这三组测试组成三层故障定位
| 测试层 | 使用什么 | 主要保护什么 |
|---|---|---|
| Model 层 | DeterministicModel + 假 outputs | 消息形状、顺序输出、成本、observation 方言 |
| Environment 层 | LocalEnvironment + 真实本地 shell | cwd、env、stdout/stderr、return code、timeout、进程清理 |
| Agent 层 | DeterministicModel + LocalEnvironment + DefaultAgent | query/execute/observation 循环、限制、消息历史、退出协议 |
test_default.py 严格说不是纯单元测试。它用假的 Model 消除 API 不确定性,却保留真实 LocalEnvironment,因此是小型组件集成测试。
1.5 TDD 不是“写完代码后补测试”
一轮 TDD 的价值不在颜色,而在顺序:先用调用者视角写出输入与输出,再决定内部实现。对 mini-SWE-agent 来说,新 Environment 最先要满足的是 execute/get_template_vars/serialize 的可观察协议,而不是先复制 LocalEnvironment。
2. 5W2H
| 项目 | 本课答案 |
|---|---|
| What | 三组 pytest 测试分别保护测试 Model、LocalEnvironment 和 DefaultAgent 的合同,并演示怎样测试驱动地添加替代实现。 |
| Why | AI API 非确定、shell 有副作用、Agent 循环跨组件;分层测试能让失败可复现,并迅速定位责任边界。 |
| Who | pytest 收集并运行测试;fixture 准备状态;DeterministicModel 提供预录回复;LocalEnvironment 或 RecordingEnvironment 执行 action;assert 验证外部行为。 |
| When | 新功能前先写失败测试;最小实现后跑局部测试;重构后跑三层回归;真实 API 只留给明确标记的 fire/integration 场景。 |
| Where | test_test_models.py 测 Model 替身,test_local.py 测执行边界,test_default.py 测三者组合后的核心循环。 |
| How | fixture、参数化、真实临时目录、结构化 fake、流程异常和行为断言共同构造稳定场景。 |
| How much | Model 测试毫秒级;Local 测试需要进程;Agent 文件因三方言参数化和真实 sleep 约 13 秒,真正 API 成本为 0。 |
3. 从测试到生产代码的完整关系
测试文件本身不会被业务代码 import。pytest 依靠文件名 test_*.py 和函数名 test_* 自动发现它们;测试再反向调用生产对象,验证公开行为。
4. pytest 最小语法桥
4.1 fixture:按名字提供测试依赖
@pytest.fixture
def default_config():
return {...}
def test_something(default_config):
...
近似 JS 的 beforeEach,但 fixture 有返回值、依赖图和作用域。默认 scope 是 function,每个测试用例重新执行一次。
4.2 yield fixture:前半段 setup,后半段 teardown
tests/conftest.py 中:
@pytest.fixture
def reset_global_stats():
with lock:
reset()
yield
reset()
yield 前在测试前运行,yield 后无论测试通过还是失败都会清理,近似:
beforeEach(reset);
afterEach(reset);
fixture 放在 tests/conftest.py 后,子目录测试可以按参数名使用,无需 import。
4.3 参数化 fixture:一份测试跑三种实现
@pytest.fixture(params=["text", "toolcall", "response_api"])
每个依赖该 fixture 的测试都会被展开为三个 case。这是协议测试:不关心三种 Model 内部怎样格式化,只要求 DefaultAgent 对三者表现一致。
4.4 context manager:进入时准备,离开时清理
with tempfile.TemporaryDirectory() as temp_dir:
...
近似:
const tempDir = await fs.mkdtemp(...);
try {
// test
} finally {
await fs.rm(tempDir, { recursive: true });
}
4.5 pytest.raises:异常就是预期输出
with pytest.raises(FormatError):
model.query(messages)
只要 block 内没有抛指定异常,测试就失败。需要检查异常携带的 payload 时,可写 as exc_info 再读取 exc_info.value。
4.6 参数化表格
仓库规范要求第一个参数是 tuple,第二个参数是 list:
@pytest.mark.parametrize(
("command", "expected_returncode"),
[
("echo test", 0),
("exit 1", 1),
],
)
这近似 test.each([[...], [...]]),避免为同一规则复制多个测试函数。
5. 逐行读 tests/agents/test_default.py:1-511
第 1-16 行:测试依赖
- 第 1 行
Path找 YAML。 - 第 3-4 行导入 pytest 与 PyYAML。
- 第 6 行是主要被测对象 DefaultAgent。
- 第 7 行使用真实 LocalEnvironment,所以会启动本地 shell。
- 第 8 行 FormatError 服务后面的异常路径测试。
- 第 9-16 行导入三种 DeterministicModel 和对应消息构造 helper。
这套组合刻意只替换不稳定的 Model API,保留 Agent 与 Environment 的真实协作。
第 18-30 行:统一提取 message 文本
get_text(msg) 处理三种 content:
None→ 空字符串。- 普通字符串 → 原样返回。
- Responses API content list → 取第一项的
text。
msg.get("content") 在 key 不存在时返回 None,不会像 msg["content"] 那样抛 KeyError。
风险:只取 list 第一项,多段 content 会被忽略。
第 33-37 行:统一 observation 文本
Responses API 的 function result 用 output 字段;Chat/toolcall 与文本方言通常读 content。这个 helper 让后面断言只关心 observation 语义,不必每次写三个分支。
第 40-53 行:识别 assistant 与 observation
assistant 判定:
- Chat 风格:
role == "assistant"。 - Responses 风格:
object == "response"。
observation 判定:
- Responses:
type == "function_call_output"。 - Tool call:
role == "tool"。 - 文本:role=user 且文本含
returncode。
最后一条是启发式判断;普通用户消息碰巧写了 returncode 也会被误判。
第 56-74 行:两个真实 YAML fixture
default_config读取config/default.yaml["agent"]。toolcall_config读取config/mini.yaml["agent"]。with open(...)离开 block 后自动关闭文件。
这些 fixture 让测试覆盖真实默认配置,但也让 Agent 测试与 prompt 文案、默认 limits 耦合。按当前项目风格,新代码通常更偏向 Path.read_text()。
第 77-79 行:文本模型工厂
输入是:
[(content, actions), ...]
列表推导式把每组数据变成 make_output(...),近似 JS 的 outputsSpec.map(...)。
第 82-99 行:Chat Completions tool-call 工厂
- 第 84 行准备 outputs。
- 第 85 行
enumerate同时得到轮次 index 与元素,近似forEach((item, i) => ...)。 - 第 88 行内层循环处理同一轮多个 action。
- 第 89 行生成稳定
call_i_j。 - 第 90 行保存 Agent 真正读取的 normalized action。
- 第 91-97 行同时构造 provider 风格 tool call。
- 第 98 行把两种表达装进同一 assistant message。
- 第 99 行创建 DeterministicToolcallModel。
第 95 行用 f-string 手拼 JSON。命令含双引号、反斜杠或换行时可能无效;这些测试仍可能通过,因为 Agent 直接读 extra.actions,不会重新解析测试 helper 里的 arguments。
第 102-113 行:Responses API 工厂
它同样生成稳定 tool-call ID,但交给 make_response_api_output() 构造 Responses wire format。两种 factory 最终都把统一 action 放在 extra.actions,这是 Agent 多态的关键。
第 116-124 行:一份测试扩成三份
model_factory 根据 request.param 返回:
(factory_function, agent_config)
Python 解构:
factory, config = model_factory
等价于 JS:
const [factory, config] = modelFactory;
前 15 个使用该 fixture 的测试各跑三次;最后两个 FormatError 测试只跑一次,所以整个文件是 15 × 3 + 2 = 47 cases。
第 130-150 行:成功完成的最小闭环
Arrange:预录两轮回复,第一轮 echo,第二轮打印完成标记和 submission。
Act:调用 agent.run(...)。
Assert:退出状态为 Submitted、submission 文本准确、模型调用两次。
它同时保护:
- DeterministicModel 顺序回复。
- Agent 循环。
- LocalEnvironment stdout。
- 完成标记到 Submitted 的退出协议。
第 153-169 行:step limit 在下一次 query 前拦截
配置 step_limit=1。第一轮 query、action、observation 正常完成;进入第二轮 query 前,父类发现 n_calls == limit,生成 LimitsExceeded exit。
断言 n_calls == 1 能抓住 off-by-one。
第 172-183 行:cost limit 不是第一笔请求的硬上限
默认 Deterministic message cost 是 1.0,limit 设为 0.5。初始 cost 是 0,所以第一轮仍会调用并执行;下一轮 query 前才发现累计 cost 超限。
当前测试只断言 exit status,没有断言最终 cost、调用次数或 action 确实执行,保护力度比 step-limit 测试弱。
第 185-204 行:Environment timeout 变普通 observation
首轮命令 sleep 5,但 LocalEnvironment timeout 是 1 秒。Environment 不把 TimeoutExpired 继续抛给 Agent,而是返回:
{
"returncode": -1,
"exception_info": "...timed out...",
"extra": {"exception_type": "TimeoutExpired", ...},
}
模型的第二条预录回复再提交。最终 Submitted 证明 timeout 可观察、可恢复,不等于 Agent 终止。
第 207-228 行:timeout 仍保留 partial stdout
命令先计算并 echo 999,再 sleep。timeout 后测试不只检查错误,还检查 observation 中仍有 999。
这保护 _run() 在 kill process group 后再次 communicate 并保留已产生 stdout 的行为。
第 231-253 行:四轮循环与预算
三轮普通命令,第四轮提交。默认每次成本为 1,测试把 cost limit 提到 5,避免在第四轮前停止。
最终 n_calls == 4 同时验证循环次数与 limit 配置。
第 256-282 行:自定义模板确实进入初始 messages
配置通过:
**{
**config,
"system_template": "...",
"instance_template": "Task: {{task}}...",
}
这近似 JS { ...config, system_template: "..." }。后面的键覆盖原值。
断言消息 0 是 system template,消息 1 含 task,保护配置到 Jinja 再到 Model message 的链路。
第 285-306 行:模板变量能看到 Agent 统计
测试没有调用完整 run,而是:
- 手动加入 system/user。
- 调 query 两次。
- 渲染
n_model_calls与model_cost。
预期 Calls: 2, Cost: 2.0,保护 get_template_vars() 合并运行状态。
第 309-330 行:timestamp 测试描述比断言更强
测试名和 docstring 声称 assistant 与 observation 都有 timestamp,但实际:
- 第 326-327 行只强制每条 assistant message 有 timestamp。
- 第 329-330 行只检查“已经含 timestamp 的消息”值为 float。
- 没有断言每条 observation 必须有 timestamp。
这是典型审查方法:不要只读测试名,要看 assert 真正排除了哪些错误实现。
第 333-361 行:message 历史顺序
预期六条:
system → user → assistant → observation → assistant → exit
最后一个提交 action 在 Environment 中直接抛 Submitted,所以不会再产生普通 observation。helper 让三种消息协议都按语义断言。
第 364-384 行:直接测试一次 step
先手动放入 system/user,记录初始数量,再调用 step()。
一次单 action step 应新增:
- assistant message。
- observation message。
这直接证明 step() 是 query 加 execute_actions,而不是只取模型回复。
第 387-406 行:中间 observation 的数量与顺序
两条普通命令分别输出 first、second,第三条提交。筛选 observation 后应恰好两条且顺序一致。
如果 action 执行或 messages append 被错误重排,这里会失败。
第 409-425 行:wall-time 在命令结束后的下一轮检查
LocalEnvironment 先真实 sleep 2 秒;Agent wall-time limit 是 1 秒。第一次 query 已经发生,命令也执行完,第二次 query 前才产生 TimeExceeded。
因此 n_calls == 1。wall-time 不会在正在执行的 Environment command 中间抢占;命令级 timeout 是 Environment 的另一套机制。
第 428-439 行:wall-time 配置与 elapsed 进入模板变量
无需运行完整循环,直接调用 get_template_vars()。测试验证:
- elapsed 是 int。
- wall_time_limit_seconds 保持 3600。
第 442-459 行:空 actions 是合法的空 step
第一条 Model message 的 actions 是空 list。execute_actions() 得到空 outputs,formatter 返回空 observation;run 不退出,继续 query 第二条并提交。
这说明文本 DeterministicModel 可以产生空 action。但真实 tool-call Model parser 通常会把“无工具调用”视为 FormatError;测试替身与生产 parser 的边界不能混淆。
第 462-477 行:手写 Flaky Model,不使用 mock 框架
_FlakyToolcallModel 继承 DeterministicToolcallModel。带 _format_error 标记的预录 output 会抛 FormatError,否则原样返回。
它是一个小型 fake:行为明确、可复用、没有 monkeypatch Model 内部方法。
第 480-492 行:连续格式错误达到阈值后退出
安排五个错误,但阈值设为 2。第二个 FormatError 后应直接生成 RepeatedFormatError exit,不应消费后三条。
n_calls == 2 很重要:Agent 在调用 Model 前已经增加计数,即使 query 抛 FormatError,也算一次尝试。
第 495-511 行:成功 step 重置“连续”计数
磁带顺序:
error → success → error → submit
中间成功 step 把计数归零,所以两个错误不相邻,不会提前达到阈值 2。
6. 逐行读 tests/environments/test_local.py:1-264
第 1-12 行:标准库、测试工具与被测对象
os:cwd、环境变量、进程存在检查。shlex:把 Python executable、脚本和 PID 文件路径安全引用进 shell command。signal:测试失败时清理残留进程。sys:取得当前 Python executable。tempfile、Path:隔离文件系统副作用。time:轮询等待。patch:临时修改os.environ;这是已有测试写法,新练习不需要 mock/patch。- pytest:参数化与平台 skip。
- LocalEnvironment 与其配置类:被测对象。
第 15-21 行:配置默认值
直接构造 LocalEnvironmentConfig,断言 cwd、env、timeout 三个默认值。它能快速定位 schema 变化,但不证明 execute 使用了这些值。
第 24-30 行:最小真实执行
Arrange:默认 Environment。
Act:执行 echo。
Assert:return code 为 0 且 stdout 包含文本。
这里没有 mock subprocess,确实启动 shell。
第 33-46 行:配置注入多个环境变量
同一个 env 连续执行两个 command,分别检查单变量和多变量。它验证 config env 被传进子进程,但不意味着 shell 状态在两次 action 间持续;每次 execute 都是新进程。
第 48-57 行:宿主环境与配置环境合并
patch.dict(os.environ, ...) 在 with block 内临时加入 EXISTING_VAR,离开后恢复。Environment 再加入 NEW_VAR,子进程应同时看到两者。
第 59-66 行:配置覆盖同名宿主变量
LocalEnvironment 使用:
os.environ | self.config.env
Python dict merge 右侧优先,所以 CONFLICT_VAR 应变成 override_value。
第 69-77 行:config cwd
TemporaryDirectory 自动创建并清理目录。Environment config 指定 cwd,pwd 输出必须指向它。
在 macOS 上路径可能存在 /var 与 /private/var 的规范化差异,简单字符串包含断言有潜在平台脆弱性。
第 79-88 行:execute 参数优先于 config cwd
同时创建两个临时目录:config 用 temp_dir1,单次 execute 传 temp_dir2。断言输出是 temp_dir2,保护优先级:
execute(cwd) > config.cwd > os.getcwd()
第 90-97 行:都没指定时使用进程当前目录
先记录 os.getcwd(),再执行 pwd 并比较。它保护 fallback,但同样要注意符号链接和规范化路径。
第 100-107 行:非零退出不是 Python 异常
exit 1 的 shell 正常结束,只是 return code 非零。Environment 返回结构化 output,不抛异常:
{"returncode": 1, "output": "", ...}
Agent 因此可以把失败反馈给模型继续修复。
第 109-115 行:不存在的命令
shell 自己把错误写进输出,return code 非零。不同 shell 的文案不同,所以断言允许“包含命令名”或“command not found”。
第 118-124 行:stderr 合并进 output
命令把文本重定向到 stderr,但 LocalEnvironment 的 Popen 使用 stderr=subprocess.STDOUT,所以统一从 result["output"] 读取。
第 127-135 行:timeout 被包装,不向调用者抛出
sleep 2、timeout 1。断言三个维度:
- returncode 是 -1。
- exception_info 可读。
- extra.exception_type 是 TimeoutExpired。
这比只断言一个错误字符串更能保护 output contract。
第 137-168 行:timeout 必须杀掉 shell 的子进程
skipif(os.name == "nt") 表示进程组逻辑只在 POSIX 运行。
测试步骤:
- 在临时目录写一个无限循环 Python 脚本。
- 脚本先把自己的 PID 写入文件。
- shell 启动这个 child。
- LocalEnvironment timeout 后 kill 整个 process group。
- 测试读 PID 并确认 child 已退出。
- finally 中兜底 SIGKILL,避免失败测试留下进程。
shlex.quote() 很关键:temp path 若含空格或 shell 字符,仍作为单个参数传递。
第 170-175 行:轮询等待 PID 文件
最多 50 次,每次 0.1 秒。walrus operator 同时读取并保存 content:
content := pid_file.read_text().strip()
超时后抛 AssertionError,错误信息带 PID 文件路径。
第 178-185 行:用 signal 0 检查进程存在
os.kill(pid, 0) 不真的发送信号,只检查该 PID 是否存在和是否可访问。ProcessLookupError 表示已退出;否则等待并重试。
第 188-193 行:失败后的进程清理
兜底发送 SIGKILL。进程已经消失时忽略 ProcessLookupError。这段 cleanup 防止失败路径污染开发机和后续测试。
第 195-200 行:自定义 timeout config
先构造 LocalEnvironmentConfig,再用 config.__dict__ 展开给 Environment。当前 Pydantic 风格通常更适合 model_dump(),但测试意图只是验证 5 被保存。
第 203-217 行:一张表覆盖三种 return code
参数名使用 tuple,cases 使用 list,符合仓库规范。pytest 将函数展开为三项:0、1、42。
只有期望数据变化、行为结构相同时才适合参数化;不要把完全不同场景硬塞进一张表。
第 219-229 行:多行 stdout
执行带换行的 echo,strip 后 split,断言行数与顺序。echo -e 在不同 /bin/sh 中细节并不完全一致,这个测试有平台相关性。
第 232-249 行:文件副作用被限制在临时目录
先通过 Environment 创建文件,再通过 Environment 读取,最后用 Path 从测试进程独立验证文件存在与内容。
三层断言能区分:命令 return code、shell 输出、真实文件系统结果。
第 252-264 行:明确要求 shell features
测试 pipe 和 command substitution,证明 shell=True 是产品能力而非偶然实现细节。若未来改成 subprocess.run([command, args...]),这些用例会立即指出破坏性变化。
7. 逐行读 tests/models/test_test_models.py:1-215
第 1-18 行:依赖与三种离线模型
- logging 与 time 服务 warning/sleep 测试。
- pytest 服务 raises 和 fixture。
- 导入整个
minisweagent.models,是为了每次读取当前 GLOBAL_MODEL_STATS,而不是复制一个值。 - FormatError 服务显式异常 action。
- 第 8-18 行导入三套 model/config/helper。
外部 fixture:reset_global_stats
它定义在 tests/conftest.py:26-40,测试函数只需声明同名参数。
fixture 用 lock 保证修改全局统计时的线程互斥,并在 yield 前后都把 _cost、_n_calls 清零。它直接改受保护字段,说明当前 GlobalModelStats 没有公开 reset API。
第 21-42 行:磁带顺序与默认全局成本
准备两条 outputs,连续 query 两次。每轮同时断言:
- content 是对应磁带项。
- extra.actions 正确。
- 全局调用数加一。
- 全局成本每次加默认 1.0。
注意 code block 只是 content;actions 已由 make_output 直接提供,DeterministicModel 不解析代码块。
第 45-61 行:多个 Model 共享同一全局统计器
model1 cost_per_call=2.5,model2=3.0。两实例 query 后全局 cost 是 5.5、调用数是 2。
这里测试的是 GLOBAL,而不是每个 Agent 实例的 cost。
第 64-75 行:名字叫 dataclass,实际是 Pydantic BaseModel
DeterministicModelConfig 不是 Python @dataclass。测试:
- 构造带自定义字段的 Pydantic config。
- 读取校验后的属性。
model_dump()变 dict。- 用
**dict重建 Model。
测试名 test_config_dataclass 是历史命名,阅读时以类定义为准。
第 77-90 行:/sleep 是测试 Model 的内部控制指令
第一条预录 action 是 /sleep 0.1。_process_test_actions() 真正 sleep,然后让 query 递归读取下一条 output;调用者最终只收到 after_sleep。
断言墙钟至少经过 0.1 秒,证明 special action 被处理。
它不会交给 Environment,不是产品 shell 命令。
第 91-102 行:/warning 与 caplog
caplog.at_level(logging.WARNING) 临时捕获 warning 日志。第一条磁带触发 warning,递归返回第二条正常 output;最后既断言 response,也断言日志文本。
第 104-109 行:预录 action 可以直接抛异常
{"raise": FormatError()} 是另一个测试控制协议。pytest.raises 证明 query 原样传播异常,不返回普通消息。
第 111-126 行:Chat tool-call 的双重表达
同一动作同时写成:
- provider wire format 的
tool_calls。 - Agent 统一格式的
extra.actions。
query 后两份都必须保留,且全局调用数加一。
这仍不测试生产 parser;两份表达都由测试作者直接提供。
第 128-145 行:假的 Environment output 变 tool result
测试不创建 Environment,直接手造标准 output:
{"output": "/home/user", "returncode": 0, "exception_info": ""}
再调用 Model formatter,断言 role=tool、tool_call_id 没丢、content 含输出。
这是小而快的 Model contract test。
第 147-158 行:tool-call 配置往返
自定义 model name/cost,model_dump 后重建 Model,断言 cost 保留。与文本模型 config 测试结构相同。
第 160-177 行:Responses API 基本消息形状
一个带文字和 action 的 response 应有两个 output items:
- message。
- function_call。
断言 object=response、统一 actions、全局统计与 call ID。
第 179-193 行:Responses observation 方言
同一份假 output 被格式化为:
type="function_call_output"- 原 call ID
- output 文本
它和上一节的 role=tool 是平行协议,不应强行断言成相同 wire shape。
第 195-203 行:普通 user message 也要适配 Responses API
format_message(role="user", content="Hello") 变为 input_text 数组。Agent 的初始 system/user message 因此也能使用 Responses Model。
第 205-215 行:Responses 配置往返
与前两种 Model 相同:构造 config、读自定义字段、model_dump、重建对象。
8. 三种测试替身不要混称 mock
| 对象 | 更准确名称 | 原因 |
|---|---|---|
| DeterministicModel | Stub/Fake | 返回预录值,同时实现真实 Model formatter 与统计行为 |
_FlakyToolcallModel | Fake | 根据明确输入标记产生格式错误 |
| 练习中的 RecordingEnvironment | Spy/Fake | 实现 Environment 行为,同时记录收到的 actions 供断言 |
| LocalEnvironment | Real implementation | 真正启动 shell,不是替身 |
Mock 通常强调“某方法被调用几次、参数是什么”的交互预期,并由框架动态生成。这个仓库更适合小型手写 fake:形状清楚、可读、符合最小化目标,也遵守“不随意 mock/patch”的测试规范。
9. 如何测试驱动地添加一个 Environment
按由内到外的三圈写:
第一圈:Environment contract
给一个 action,断言标准 output 至少包含:
{
"output": str,
"returncode": int,
"exception_info": str,
}
同时断言扩展特有行为,例如 RecordingEnvironment 确实记录 command 和 cwd。
第二圈:与 DefaultAgent 的组件集成
注入 DeterministicModel,让第一轮产生普通 action、第二轮产生提交 action。断言:
agent.run()返回 Submitted payload。- Environment 收到正确顺序。
- messages 形成 system/user/assistant/observation/assistant/exit。
第三圈:边界行为
选择一个高价值边界,例如 step limit,证明下一轮模型和第二个 action 没有发生。不要先穷举所有字段。
只有当扩展需要通过配置工厂或 CLI 对外暴露时,再增加第三层之外的 factory/run integration test。
10. 主要符号与使用位置
| 符号 | 定义 | 本课直接使用位置 | 生产消费者 |
|---|---|---|---|
DeterministicModel | src/minisweagent/models/test_models.py:104 | test_test_models.py、test_default.py 及大量其他测试 | 主要是测试;Model 工厂也映射短名 deterministic |
make_output | test_models.py:16 | 两个重点测试文件和练习 | 构造文本方言测试消息 |
DeterministicToolcallModel | test_models.py:160 | Model 测试、Agent 参数化测试 | 复用生产 tool observation formatter |
DeterministicResponseAPIToolcallModel | test_models.py:204 | Model 测试、Agent 参数化测试 | 复用 Responses observation formatter |
LocalEnvironment | src/minisweagent/environments/local.py:19 | test_local.py、test_default.py | run/mini.py 默认 Environment,Agent 调用 execute |
DefaultAgent | src/minisweagent/agents/default.py:38 | test_default.py 与练习 | hello_world、工厂、InteractiveAgent 父类 |
reset_global_stats | tests/conftest.py:26 | 依赖全局成本断言的 Model 测试 | pytest 按参数名注入,不属于生产代码 |
model_factory | test_default.py:116 | 前 15 个 Agent test functions | pytest 参数化注入,不属于生产代码 |
检索调用位置:
rg -n --glob '*.py' 'DeterministicModel|LocalEnvironment|DefaultAgent|reset_global_stats|model_factory' src tests
11. 当前测试覆盖矩阵
| 行为 | Model 测试 | Environment 测试 | Agent 测试 |
|---|---|---|---|
| 预录回复顺序 | 是 | - | 间接 |
| 三种消息方言 | 是 | - | 是,参数化 |
| 全局成本 | 是 | - | - |
| cwd/env 优先级 | - | 是 | - |
| return code/stdout/stderr | - | 是 | 间接 |
| timeout 与 child cleanup | - | 是 | timeout 恢复 |
| query/execute 循环 | - | - | 是 |
| step/cost/wall limits | - | - | 是 |
| message 顺序 | - | - | 是 |
| FormatError 连续计数 | 可制造异常 | - | 是 |
| Submitted | - | 未直接测试 | 通过 Agent 间接测试 |
| serialize/save | config 部分 | 未覆盖 | 此文件未覆盖 |
定位经验:
test_test_models.py先失败,优先看测试 Model 或 formatter。test_local.py先失败,优先看 subprocess、平台或 Environment contract。- 前两者都绿、
test_default.py失败,优先看 Agent 编排或跨组件假设。
12. 已验证结果
tests/agents/test_default.py
47 passed in 13.20s
tests/environments/test_local.py
20 passed in 2.33s
tests/models/test_test_models.py
12 passed in 0.30s
08_test_driven_extension.py
3 passed in 0.24s
三组重点测试合计 79 cases。它们不会调用模型 API;但 Agent 和 LocalEnvironment 两组会真实执行硬编码本地 shell 命令。练习的 3 cases 既不调用 API,也不启动 shell。
13. 测试缺口与容易误读之处
Agent 测试缺口
- timestamp 用例没有真正强制 observation 带 timestamp。
- 没有覆盖同一轮多个 actions 的顺序与 tool-call ID 对齐。
- 没有测试 step/cost limit 为 0、刚好等于阈值等边界。
- cost-limit 用例断言较弱,没有检查最终 cost 与调用数。
- 没有覆盖同一 Agent 连续 run 两次时累计状态语义。
- 没有覆盖 output_path、save/serialize、未定义 Jinja 变量与普通未捕获异常。
- FormatError 没测阈值 0 和 1。
Environment 测试缺口
- 没有在本文件直接测试完成标记如何抛 Submitted;目前主要由 Agent 测试间接覆盖。
- 没有覆盖 action 缺少 command、command 非字符串。
- 没有测试 execute 的
timeout=0与 config timeout 的 truthy fallback 语义。 - cwd 的符号链接与不存在目录错误只有间接行为。
- 没有直接断言正常 output 的 exception_info 是空字符串。
Test Model 测试缺口
- outputs 用尽时会发生 IndexError,没有明确契约测试。
- special
/sleep、/warning测试会改 GLOBAL_MODEL_STATS,却没有使用 reset fixture。 make_output(cost=...)的 message cost 与cost_per_call的 global cost 可以不同,未测试两套统计语义。- tool-call wire data 与 extra.actions 都由测试直接给出,不经过生产 parser。
- 手拼 JSON 没覆盖双引号、换行和反斜杠。
- serialize、多模态、多个 outputs/actions 与 formatter 异常缺少覆盖。
14. 可维护性、性能与安全审查
可维护性
- 跨方言 helper 在 Agent 与 InteractiveAgent 测试中重复。 抽共享 helper 可减少维护;风险是测试基础设施变复杂,并掩盖每个文件真正依赖的消息细节。
- Agent 测试读取完整 YAML。 少数配置集成测试应保留;多数核心循环测试改最小内联 config 可减少 prompt 文案变化造成的噪声。风险是遗漏默认配置兼容问题。
- tool-call arguments 应使用
json.dumps。 能覆盖任意 command;风险主要是快照转义文本变化,行为风险低。 test_config_dataclass命名不准确。 改为 Pydantic config 能降低初学者误解,几乎无运行风险。- GLOBAL stats 缺公开 reset。 fixture 直接写保护字段;新增 reset API 会更清晰,但会扩大生产接口。
- 测试名不能替代断言。 timestamp 用例应筛出 observations 并逐条断言,否则实现退化仍可能绿色。
性能
test_default.py的 15 项测试跑三种方言是有价值的合同覆盖,但真实 sleep 也被放大三倍。- 两组 1 秒 Environment timeout 加一组 2 秒 wall-time,理论等待约 12 秒,与实测 13.20 秒接近。
- 可把纯 Agent limit 测试换成 RecordingEnvironment/fake clock,再保留少量真实 Local 集成测试。
- fake clock 需要给生产代码引入时钟注入点;这是接口变化,不能只为测试速度轻率添加。
- LocalEnvironment 每项都启动进程,天然比内存 fake 慢;20 项 2.33 秒仍可接受。
- PID 轮询在失败路径最多等待数秒,能换来“不遗留后台进程”的可靠性。
安全
- Agent 和 Environment 测试会以当前用户权限运行 shell,不能把外部输入直接拼进测试 command。
- 文件操作已放临时目录;部分 Agent echo/sleep 在仓库 cwd 执行,虽然当前无写操作,未来改命令要谨慎。
- timeout child 测试正确使用 shlex.quote 并在 finally 清理 PID,这两点不能在重构时删除。
- “无 API”不等于“无副作用”;本地进程、文件、环境变量和时间等待仍是副作用。
- 不应把真实 API key 放进测试 config 或快照;需要真实 API 的 fire tests 必须显式 opt-in。
改进风险速查
| 改进 | 收益 | 风险 |
|---|---|---|
| Agent 测试全部换内存 Environment | 快很多、无 shell | 丢失 Agent 与真实 output/Submitted 的集成证据 |
| 只保留一方言跑慢 timeout | 节省约三分之二时间 | 其他 formatter 的 timeout observation 兼容可能回归 |
| shared test helper | 减少重复 | helper bug 会同时掩盖多组测试问题 |
| exact message snapshots | 审查完整轨迹方便 | provider 字段演化造成大量脆弱更新 |
| fake clock/sleeper 注入 | 快速确定 | 扩大生产接口与构造配置 |
| LocalEnvironment 直接测 Submitted | 补齐核心退出合同 | 低;需精确覆盖 marker 位置和 return code |
| 所有 GLOBAL 相关测试统一 fixture | 提高隔离 | 低;并行策略与 lock 使用要一致 |
15. 无 API、无 shell 小练习
练习脚本已作为本文附件提供。
文件刻意把三个 test_* 写在实现前面:Python import 模块时先创建测试函数,再创建后面的 class;pytest 在整个模块加载完后才执行测试,所以运行时 class 已经存在。这让文件阅读顺序保持“测试先行”。
三个测试分别是三圈:
- RecordingEnvironment 的标准 output 与记录行为。
- DeterministicModel + RecordingEnvironment + DefaultAgent 完整运行。
- step limit 阻止第二个 action。
运行:
MSWEA_SILENT_STARTUP=1 PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src \
.venv/bin/pytest -q exercises/08_test_driven_extension.py
已验证:
... [100%]
3 passed
做一次真正的 Red → Green
先在实现上方新增测试:
def test_failure_action_returns_standard_error():
assert RecordingEnvironment().execute({"command": "fail: broken"}) == {
"output": "",
"returncode": 1,
"exception_info": "broken",
}
第一遍运行应失败,因为当前实现会返回成功 output。确认失败信息正好指出 returncode/output/exception_info 差异,这就是 Red。
然后只在 execute() 中加入识别 fail: 的最小分支,使四个测试通过,这就是 Green。最后再考虑把 submit: 与 fail: 的 prefix 解析提成小 helper;提取前后都运行四个测试,这就是 Refactor。
练习观察重点:
- 不需要 mock Agent 或 Model 网络层。
- 测试只依赖 Environment 的公开 execute contract。
- End-to-end test 证明替代实现满足 Protocol 的真实消费者。
- limit test 证明第二个 action 没有被记录,比只看 exit status 更强。
16. 三道检查题(请先回答,不要查答案)
model_factory为什么能让同一个 Agent 测试自动运行三次;这三次相同的是哪个内部合同,不同的又是什么 wire format?- LocalEnvironment timeout 与 DefaultAgent wall-time limit 分别在哪一层生效;为什么两个测试都可能只在下一步观察到“停止”,但含义不同?
- 给 mini-SWE-agent 新增 RecordingEnvironment 时,Red、Green、Refactor 三阶段各应做什么;为什么第二圈要使用 DeterministicModel 而不是调用真实 API?