本系列基于 mini-SWE-agent 源码学习整理;源码版本固定为 Asuhe404/mini-swe-agent@388da74,便于长期核对实现。

配套练习:下载 08_test_driven_extension.py

系列导航

返回路线图 · 上一篇:07. 配置、工厂与交互子类:从 CLI 参数装配出可控 Agent

适合:零 Python 基础、已有 JavaScript 基础。
重点文件:

  • tests/agents/test_default.py
  • tests/environments/test_local.py
  • tests/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

pytestVitest/Jest 类比
assert value == expectedexpect(value).toEqual(expected)
assert "x" in textexpect(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 + 真实本地 shellcwd、env、stdout/stderr、return code、timeout、进程清理
Agent 层DeterministicModel + LocalEnvironment + DefaultAgentquery/execute/observation 循环、限制、消息历史、退出协议

test_default.py 严格说不是纯单元测试。它用假的 Model 消除 API 不确定性,却保留真实 LocalEnvironment,因此是小型组件集成测试。

1.5 TDD 不是“写完代码后补测试”

流程图 1流程图 1

一轮 TDD 的价值不在颜色,而在顺序:先用调用者视角写出输入与输出,再决定内部实现。对 mini-SWE-agent 来说,新 Environment 最先要满足的是 execute/get_template_vars/serialize 的可观察协议,而不是先复制 LocalEnvironment。

2. 5W2H

项目本课答案
What三组 pytest 测试分别保护测试 Model、LocalEnvironment 和 DefaultAgent 的合同,并演示怎样测试驱动地添加替代实现。
WhyAI API 非确定、shell 有副作用、Agent 循环跨组件;分层测试能让失败可复现,并迅速定位责任边界。
Whopytest 收集并运行测试;fixture 准备状态;DeterministicModel 提供预录回复;LocalEnvironment 或 RecordingEnvironment 执行 action;assert 验证外部行为。
When新功能前先写失败测试;最小实现后跑局部测试;重构后跑三层回归;真实 API 只留给明确标记的 fire/integration 场景。
Wheretest_test_models.py 测 Model 替身,test_local.py 测执行边界,test_default.py 测三者组合后的核心循环。
Howfixture、参数化、真实临时目录、结构化 fake、流程异常和行为断言共同构造稳定场景。
How muchModel 测试毫秒级;Local 测试需要进程;Agent 文件因三方言参数化和真实 sleep 约 13 秒,真正 API 成本为 0。

3. 从测试到生产代码的完整关系

流程图 2流程图 2

测试文件本身不会被业务代码 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:

  1. None → 空字符串。
  2. 普通字符串 → 原样返回。
  3. 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,而是:

  1. 手动加入 system/user。
  2. 调 query 两次。
  3. 渲染 n_model_callsmodel_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 应新增:

  1. assistant message。
  2. 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。
  • tempfilePath:隔离文件系统副作用。
  • 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 运行。

测试步骤:

  1. 在临时目录写一个无限循环 Python 脚本。
  2. 脚本先把自己的 PID 写入文件。
  3. shell 启动这个 child。
  4. LocalEnvironment timeout 后 kill 整个 process group。
  5. 测试读 PID 并确认 child 已退出。
  6. 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。测试:

  1. 构造带自定义字段的 Pydantic config。
  2. 读取校验后的属性。
  3. model_dump() 变 dict。
  4. **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:

  1. message。
  2. 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

对象更准确名称原因
DeterministicModelStub/Fake返回预录值,同时实现真实 Model formatter 与统计行为
_FlakyToolcallModelFake根据明确输入标记产生格式错误
练习中的 RecordingEnvironmentSpy/Fake实现 Environment 行为,同时记录收到的 actions 供断言
LocalEnvironmentReal 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. 主要符号与使用位置

符号定义本课直接使用位置生产消费者
DeterministicModelsrc/minisweagent/models/test_models.py:104test_test_models.pytest_default.py 及大量其他测试主要是测试;Model 工厂也映射短名 deterministic
make_outputtest_models.py:16两个重点测试文件和练习构造文本方言测试消息
DeterministicToolcallModeltest_models.py:160Model 测试、Agent 参数化测试复用生产 tool observation formatter
DeterministicResponseAPIToolcallModeltest_models.py:204Model 测试、Agent 参数化测试复用 Responses observation formatter
LocalEnvironmentsrc/minisweagent/environments/local.py:19test_local.pytest_default.pyrun/mini.py 默认 Environment,Agent 调用 execute
DefaultAgentsrc/minisweagent/agents/default.py:38test_default.py 与练习hello_world、工厂、InteractiveAgent 父类
reset_global_statstests/conftest.py:26依赖全局成本断言的 Model 测试pytest 按参数名注入,不属于生产代码
model_factorytest_default.py:116前 15 个 Agent test functionspytest 参数化注入,不属于生产代码

检索调用位置:

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/saveconfig 部分未覆盖此文件未覆盖

定位经验:

  • 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 测试缺口

  1. timestamp 用例没有真正强制 observation 带 timestamp。
  2. 没有覆盖同一轮多个 actions 的顺序与 tool-call ID 对齐。
  3. 没有测试 step/cost limit 为 0、刚好等于阈值等边界。
  4. cost-limit 用例断言较弱,没有检查最终 cost 与调用数。
  5. 没有覆盖同一 Agent 连续 run 两次时累计状态语义。
  6. 没有覆盖 output_path、save/serialize、未定义 Jinja 变量与普通未捕获异常。
  7. FormatError 没测阈值 0 和 1。

Environment 测试缺口

  1. 没有在本文件直接测试完成标记如何抛 Submitted;目前主要由 Agent 测试间接覆盖。
  2. 没有覆盖 action 缺少 command、command 非字符串。
  3. 没有测试 execute 的 timeout=0 与 config timeout 的 truthy fallback 语义。
  4. cwd 的符号链接与不存在目录错误只有间接行为。
  5. 没有直接断言正常 output 的 exception_info 是空字符串。

Test Model 测试缺口

  1. outputs 用尽时会发生 IndexError,没有明确契约测试。
  2. special /sleep/warning 测试会改 GLOBAL_MODEL_STATS,却没有使用 reset fixture。
  3. make_output(cost=...) 的 message cost 与 cost_per_call 的 global cost 可以不同,未测试两套统计语义。
  4. tool-call wire data 与 extra.actions 都由测试直接给出,不经过生产 parser。
  5. 手拼 JSON 没覆盖双引号、换行和反斜杠。
  6. serialize、多模态、多个 outputs/actions 与 formatter 异常缺少覆盖。

14. 可维护性、性能与安全审查

可维护性

  1. 跨方言 helper 在 Agent 与 InteractiveAgent 测试中重复。 抽共享 helper 可减少维护;风险是测试基础设施变复杂,并掩盖每个文件真正依赖的消息细节。
  2. Agent 测试读取完整 YAML。 少数配置集成测试应保留;多数核心循环测试改最小内联 config 可减少 prompt 文案变化造成的噪声。风险是遗漏默认配置兼容问题。
  3. tool-call arguments 应使用 json.dumps 能覆盖任意 command;风险主要是快照转义文本变化,行为风险低。
  4. test_config_dataclass 命名不准确。 改为 Pydantic config 能降低初学者误解,几乎无运行风险。
  5. GLOBAL stats 缺公开 reset。 fixture 直接写保护字段;新增 reset API 会更清晰,但会扩大生产接口。
  6. 测试名不能替代断言。 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 轮询在失败路径最多等待数秒,能换来“不遗留后台进程”的可靠性。

安全

  1. Agent 和 Environment 测试会以当前用户权限运行 shell,不能把外部输入直接拼进测试 command。
  2. 文件操作已放临时目录;部分 Agent echo/sleep 在仓库 cwd 执行,虽然当前无写操作,未来改命令要谨慎。
  3. timeout child 测试正确使用 shlex.quote 并在 finally 清理 PID,这两点不能在重构时删除。
  4. “无 API”不等于“无副作用”;本地进程、文件、环境变量和时间等待仍是副作用。
  5. 不应把真实 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 已经存在。这让文件阅读顺序保持“测试先行”。

三个测试分别是三圈:

  1. RecordingEnvironment 的标准 output 与记录行为。
  2. DeterministicModel + RecordingEnvironment + DefaultAgent 完整运行。
  3. 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. 三道检查题(请先回答,不要查答案)

  1. model_factory 为什么能让同一个 Agent 测试自动运行三次;这三次相同的是哪个内部合同,不同的又是什么 wire format?
  2. LocalEnvironment timeout 与 DefaultAgent wall-time limit 分别在哪一层生效;为什么两个测试都可能只在下一步观察到“停止”,但含义不同?
  3. 给 mini-SWE-agent 新增 RecordingEnvironment 时,Red、Green、Refactor 三阶段各应做什么;为什么第二圈要使用 DeterministicModel 而不是调用真实 API?