aider 实战复盘:一次横跨多文件的复杂代码改造——用 prompt_toolkit 替换 /dev/null 的协作式调试全流程
导读
本文基于 aider 官方提供的一则真实会话实录(complex-change.md)展开讲解:用户要求把测试代码中"用 /dev/null 重定向 stdin 以模拟无输入"的旧写法,替换为 prompt_toolkit 提供的输入 Mock 函数。这是一个典型的横跨多个源码文件的复杂改动,涉及测试文件、CLI 入口与交互层同步修改,并在过程中经历了多轮"报错 → 粘贴 API 签名 → 修正"的协作式调试。读完本文,你将掌握 aider 多文件改造的完整节奏、edit block 代码格式的读法、^C 中断与外部编辑文件等协作技巧,以及"输入/输出句柄贯穿注入"这一可测试性设计模式在 aider 自身代码中的最终落地形态。
这则实录属于 示例会话合集(Example chat transcripts) 中的一员,同目录下还收录了生成新代码、修改开源仓库、黑盒测试、数据分析和文档更新等不同类型任务的完整对话。
一、场景设定:要解决什么问题
会话开头的操作和第一句话概括了任务全貌:
> $ aider tests/test_main.py aider/getinput.py
> Added tests/test_main.py to the chat
> Added aider/getinput.py to the chat
#### instead of using /dev/null to send no input in the main tests, can we use the prompt_toolkit input mocking functions?
要点有三个:
- 启动命令:
aider <file1> <file2>把tests/test_main.py与aider/getinput.py直接加入会话。启动时指定的文件会立即进入 LLM 的"可见且可编辑"集合。 - 任务本质:测试里原先用
open(os.devnull, "r")把 stdin 指向空设备,模拟"用户不输入任何内容";现在想改用prompt_toolkit官方提供的 Mock 输入函数,让测试更贴近真实交互对象。 - 为什么是"复杂改动":
main()的调用方在测试侧,而输入行为的实际承载者在getinput.py的交互类中,两者之间还隔着aider/main.py的调用链。改测试必然牵动生产代码的签名设计——这正是多文件协调改动的最佳案例。
说明:实录发生在项目早期,当时的交互层模块名为
aider/getinput.py。在 当前仓库 中,同类职责的InputOutput类已经演进并迁移到 aider/io.py,下文"与当前仓库实现的对应"一节会展开对照。
二、阅读实录前必须先懂的会话格式
打开 examples/README.md 可以看到这段实录所用的"会话排版约定":
- 以
>开头的是 aider 工具自身的输出,例如Added xxx to the chat、Applied edit to xxx、Commit <hash> <message>。 - 以
####开头的是 用户在对话中敲入的请求或反馈。 - 其余正文(以及嵌入其中的 edit block)是 LLM 的回复。
- LLM 提出修改时会输出可机器解析的 edit block,示例格式如下:
hello.py
<<<<<<< ORIGINAL
print("hello")
=======
print("goodbye")
>>>>>>> UPDATED
即:第一行是目标文件路径;<<<<<<< ORIGINAL 到 ======= 之间是被替换的旧代码;======= 到 >>>>>>> UPDATED 之间是写入的新代码。aider 收到这种格式后会自动应用到磁盘文件并打印 Applied edit to <path>。
正如 README 所总结的会话运作机制:
- 每次 LLM 给出代码修改,aider 都会自动把它应用到源文件,对应实现可以在 base_coder.py 中看到
self.io.tool_output(f"Applied edit to {path}")(见 base_coder.py)。 - 应用完修改后,aider 会自动提交到 git,并附上一段描述性的提交信息。
- LLM 只能看到与会话中已添加的文件;用户既可以在命令行启动时加文件,也可以随时用对话内命令
/add追加。实录中就多次出现Added xxx to the chat通知。
三、第一轮改动:测试全面切换到 prompt_toolkit 输入 Mock
LLM 给出的第一套方案包含四处 edit block,分别对应 import 调整和三个测试方法的调用改写。
① import 部分:引入 create_pipe_input 与 DummyOutput
tests/test_main.py
<<<<<<< ORIGINAL
import os
import sys
import tempfile
from unittest import TestCase
from aider.main import main
import subprocess
=======
import os
import tempfile
from unittest import TestCase
from aider.main import main
import subprocess
from prompt_toolkit.input import create_pipe_input
from prompt_toolkit.output import DummyOutput
>>>>>>> UPDATED
注意这里顺手删掉了 import sys——因为新方案不再需要手工保存、恢复 sys.stdin 吗?并不尽然,后面会看到 sys.stdin 的保存恢复依然保留。这处 import 删除其实是 LLM 的一个疏漏,随后在 git 层面被捕捉(见第四节)。
② 第一个测试方法:main([])
tests/test_main.py
<<<<<<< ORIGINAL
with open(os.devnull, "r") as dev_null:
save_stdin = sys.stdin
sys.stdin = dev_null
main([])
sys.stdin = save_stdin
=======
pipe_input = create_pipe_input()
save_stdin = sys.stdin
sys.stdin = pipe_input
main([], input=pipe_input, output=DummyOutput())
sys.stdin = save_stdin
pipe_input.close()
>>>>>>> UPDATED
③ 第二个测试方法:main(["foo.txt"])
tests/test_main.py
<<<<<<< ORIGINAL
with open(os.devnull, "r") as dev_null:
save_stdin = sys.stdin
sys.stdin = dev_null
main(["foo.txt"])
sys.stdin = save_stdin
self.assertTrue(os.path.exists("foo.txt"))
=======
pipe_input = create_pipe_input()
save_stdin = sys.stdin
sys.stdin = pipe_input
main(["foo.txt"], input=pipe_input, output=DummyOutput())
sys.stdin = save_stdin
pipe_input.close()
self.assertTrue(os.path.exists("foo.txt"))
>>>>>>> UPDATED
④ 第三个测试方法:main(["--yes", "foo.txt"])
tests/test_main.py
<<<<<<< ORIGINAL
with open(os.devnull, "r") as dev_null:
save_stdin = sys.stdin
sys.stdin = dev_null
main(["--yes", "foo.txt"])
sys.stdin = save_stdin
self.assertTrue(os.path.exists("foo.txt"))
=======
pipe_input = create_pipe_input()
save_stdin = sys.stdin
sys.stdin = pipe_input
main(["--yes", "foo.txt"], input=pipe_input, output=DummyOutput())
sys.stdin = save_stdin
pipe_input.close()
self.assertTrue(os.path.exists("foo.txt"))
>>>>>>> UPDATED
这段改造透露了两个设计意图:
- 用
create_pipe_input()创建可编程写入的管道输入,模拟"无输入"场景; - 用
DummyOutput()吞掉渲染输出,避免测试时终端被 prompt 界面刷屏; - 同时大胆地给
main()传了input=、output=关键字参数——而彼时main()根本还不接受这两个参数。
aider 依次应用了四处编辑并自动提交:
> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Commit c177e29 aider: Replaced usage of /dev/null with prompt_toolkit's input mocking functions in tests.
多行 Applied edit 证明:一次对话回合内,aider 可以连续应用同一文件的多处编辑,并将它们打包成一次带描述的 git 提交。这是 edit block 机制与 git 自动化结合的直观体现。
四、对话外的改动被探测到:git 的"带外变更"提示
第一轮修改落地后,会话中出现了一段非常特殊的交互:
> Git repo has uncommitted changes.
> Suggested commit message:
> Added import statement for sys module in test_main.py.
> Commit before the chat proceeds [y/n/commit message]? y
> Commit 6724fcd Added import statement for sys module in test_main.py.
这说明用户在自己的编辑器里、在对话之外(out-of-band)手动修改了 tests/test_main.py——补回了第一节中被误删的 import sys。aider 虽然不直接感知编辑器内容,但它检测到了 git 工作区存在未提交的改动,于是在继续对话之前暂停并询问用户:
- 输入
y:采用 aider 建议的提交信息并提交; - 输入
n:不提交,直接继续; - 输入其他文字:以该文字作为自定义提交信息提交。
这是 aider 与 git 深度集成的关键设计:一方面保证每个代码改动都有清晰的提交边界,另一方面避免"对话外改动"污染下一次自动提交的可追溯性。你在本地开发时如果也想体验同样的保护,只要让仓库处于 git 管理之下并正常启动 aider 即可。
五、调试循环 #1:TypeError 暴露 main() 签名缺口,/add 把源码引入会话
第一轮"乐观"的调用改写立即在测试运行中暴露问题:
#### I am getting this error: FAILED tests/test_main.py::TestMain::test_main_with_empty_git_dir_new_file - TypeError: main() got an unexpected keyword argument 'input'
这正是多文件改动的典型连锁反应:测试侧已经按新契约调用,但生产侧签名尚未跟上。用户处理方式很规范:
- 先通过
/add aider/main.py把 CLI 入口源码加入会话(出现Added aider/main.py to the chat); - 再原样粘贴 pytest 的报错文本作为提示。
LLM 据此把 main() 扩展为接受可选参数:
aider/main.py
<<<<<<< ORIGINAL
def main(args=None):
if args is None:
args = sys.argv[1:]
=======
def main(args=None, input=None, output=None):
if args is None:
args = sys.argv[1:]
if input is None:
input = sys.stdin
if output is None:
output = sys.stdout
>>>>>>> UPDATED
这段修复的价值在于兼容性设计:默认值回落为 sys.stdin / sys.stdout,使得"无人传参"时行为与原来完全一致;而测试可以显式注入 Mock。修改被应用后自动提交:
> Applied edit to aider/main.py
> Commit 79dfba9 aider: Fixed main() function to accept input and output keyword arguments.
给读者的实践启示:当 aider 报错只涉及签名缺失时,最快的做法就是把完整 traceback 原样贴进对话,并确保相关源文件在会话内(用 /add 或启动参数添加),LLM 通常能一步到位。
六、需求澄清:把句柄贯穿到 InputOutput 与 prompt_toolkit
第一轮只修好了 main() 的"外壳",但用户随即提出了更准确的诉求——参数不能停在 main() 门口,必须真正流到读取输入的地方:
#### we need to pass those into InputOutput and to prompt_toolkit right?
这处澄清非常关键,它把问题从"让函数签名不报错"升级为"让 Mock 输入真正生效"。LLM 的回应是两段式修改:
① InputOutput.__init__ 接受并保存 input / output:
aider/getinput.py
<<<<<<< ORIGINAL
class InputOutput:
def __init__(self, pretty, yes, input_history_file, chat_history_file):
=======
class InputOutput:
def __init__(self, pretty, yes, input_history_file, chat_history_file, input=None, output=None):
self.input = input
self.output = output
>>>>>>> UPDATED
② get_input() 内调用 prompt() 时把句柄透传进去:
aider/getinput.py
<<<<<<< ORIGINAL
line = prompt(
show,
completer=completer_instance,
history=FileHistory(self.input_history_file),
style=style,
reserve_space_for_menu=4,
complete_style=CompleteStyle.MULTI_COLUMN,
)
=======
line = prompt(
show,
completer=completer_instance,
history=FileHistory(self.input_history_file),
style=style,
reserve_space_for_menu=4,
complete_style=CompleteStyle.MULTI_COLUMN,
input=self.input,
output=self.output,
)
>>>>>>> UPDATED
③ main.py 构造 InputOutput 时继续下传:
aider/main.py
<<<<<<< ORIGINAL
io = InputOutput(args.pretty, args.yes, args.input_history_file, args.chat_history_file)
=======
io = InputOutput(args.pretty, args.yes, args.input_history_file, args.chat_history_file, input=input, output=output)
>>>>>>> UPDATED
至此整条链路的雏形形成:tests → main(argv, input, output) → InputOutput(input, output) → prompt(...)。aider 应用修改并提交:
> Applied edit to aider/getinput.py
> Applied edit to aider/getinput.py
> Applied edit to aider/main.py
> Commit cea9d2a aider: Updated InputOutput class to accept input and output arguments and passed them to prompt_toolkit.prompt() function.
实践启示:当用户发现 LLM 的修改"签名对但语义不够深"时,一句精确的需求澄清("we need to pass those into X and to Y right?")就能把方向掰正。与其重新描述整个问题,不如指出缺失的那一环。
七、调试循环 #2 / #3:API 参数名两次猜错,粘贴签名加速收敛
链路打通后,prompt_toolkit 又开始拒绝参数:
#### FAILED tests/test_main.py::TestMain::test_main_with_empty_git_dir_new_file - TypeError: prompt() got an unexpected keyword argument 'input'
LLM 第一次猜测是"参数名应为 stdin / stdout",于是把 input=self.input, output=self.output 改成 stdin=self.input, stdout=self.output 并提交(Commit 55d3540)。但这依然错误——prompt_toolkit.shortcuts.prompt() 实际上不接受这些参数名。
此时用户采取了最高效的调试投喂方式:直接把 prompt_toolkit 文档中 prompt() 的完整签名抄进对话(那一长串带默认值的类型签名在实录中清晰可见)。LLM 意识到 prompt() 不支持直接注入句柄,转而提议引入 Application 类,回复进行到一半时——
> ^C KeyboardInterrupt
用户按下了 ^C 中断了这次回复。 因为该回答已经开始跑偏(Application 方案远比实际需要的复杂)。随后用户补上真正有用的信息:prompt_toolkit.shortcuts.PromptSession 类的完整构造函数签名(可以看到它接受 input: Input | None = None, output: Output | None = None)。LLM 顺势切换到正确的 PromptSession 方案:
① import 变更:
aider/getinput.py
<<<<<<< ORIGINAL
from prompt_toolkit import prompt
=======
from prompt_toolkit.shortcuts import PromptSession
>>>>>>> UPDATED
② 用 PromptSession 承载句柄,再调 session.prompt():
aider/getinput.py
<<<<<<< ORIGINAL
line = prompt(
show,
completer=completer_instance,
history=FileHistory(self.input_history_file),
style=style,
reserve_space_for_menu=4,
complete_style=CompleteStyle.MULTI_COLUMN,
input=self.input,
output=self.output,
)
=======
session = PromptSession(
message=show,
completer=completer_instance,
history=FileHistory(self.input_history_file),
style=style,
reserve_space_for_menu=4,
complete_style=CompleteStyle.MULTI_COLUMN,
input=self.input,
output=self.output,
)
line = session.prompt()
>>>>>>> UPDATED
随后提交:
> Applied edit to aider/getinput.py
> Applied edit to aider/getinput.py
> Commit ac92cca aider: Replaced `prompt()` with `PromptSession()` to correctly pass input and output arguments.
这段实录浓缩了两条重要协作经验:
- 把权威 API 签名粘贴进对话,远比让 LLM 凭记忆猜参数名高效;连猜两次参数名的代价,都不如一段签名省时。
^C是合法的协作手段:当模型回复跑偏时立即中断,再用一句澄清(或补充文档片段)重新提问,能得到质量更高的下一条回复。中断不会破坏会话上下文,前文的目标与已应用修改都仍然有效。
八、测试端最终收敛:create_input(StringIO(''))
服务端改用 PromptSession 后,用户又提出一次测试端微调:
#### try using create_input(StringIO('')) in the tests
#### the ones that currently use create_pipe_input()
即把三处 create_pipe_input() 统一替换为 create_input(StringIO('')):
tests/test_main.py
<<<<<<< ORIGINAL
from prompt_toolkit.input import create_pipe_input
=======
from prompt_toolkit.input import create_input
from io import StringIO
>>>>>>> UPDATED
以及每一处调用点:
tests/test_main.py
<<<<<<< ORIGINAL
pipe_input = create_pipe_input()
=======
pipe_input = create_input(StringIO(''))
>>>>>>> UPDATED
(同一文件中共有四处相同替换,其中一处 import、三处调用点。)
create_input(StringIO('')) 的含义是:用内存字符串流 StringIO('') 直接构造一个 prompt_toolkit 的输入对象——内容为空即"无输入",与当年 /dev/null 的语义一致,但完全在进程内完成,无需触碰真实文件描述符。全部应用并提交后:
> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Commit 4bb043f aider: Replaced the usage of `create_pipe_input()` with `create_input(StringIO(''))` in the test cases.
用户最终确认:
#### nice, that worked!
整个改造闭环完成:从 /dev/null 重定向 → create_pipe_input → create_input(StringIO('')),配套生产代码从 main() 签名到交互层完成了一次完整的依赖注入改造。
九、对照当前仓库:这次改造的"最终形态"长什么样
作为开源项目,aider 本体一直在演进。虽然 complex-change.md 记录的是历史状态,但对照 当前仓库 的源码,恰好可以验证这次改造的思路正是如今架构的基石,也方便读者把"历史对话"翻译成"现在的代码长什么样"。
1. main() 的现代签名
当前 aider/main.py 的定义为:
def main(argv=None, input=None, output=None, force_git_root=None, return_coder=False):
与实录中修复后的版本同源,只是后续又增加了 force_git_root(指定 git 根目录)与 return_coder(脚本化场景返回 Coder 对象而非执行循环)两个可选项,支撑了 scripting.md 描述的编程式调用。
2. InputOutput 已迁移并深度绑定 PromptSession
实录中的 aider/getinput.py 在现仓库中已不存在,交互核心类 InputOutput 定义在 aider/io.py。它的构造函数同样接受 input=None, output=None(见 aider/io.py),并在初始化时把句柄交给 PromptSession:
if fancy_input:
# Initialize PromptSession only if we have a capable terminal
session_kwargs = {
"input": self.input,
"output": self.output,
"lexer": PygmentsLexer(MarkdownLexer),
"editing_mode": self.editingmode,
}
...
self.prompt_session = PromptSession(**session_kwargs)
见 aider/io.py。这正是实录第八节引入 PromptSession 的延续:如今不再用函数式 prompt(),而是持有会话对象供 get_input 等流程按需调用。
此外还有一个容易被忽略的设计细节:当外部注入了 output(即非真实终端)时,InputOutput 会自动关闭彩色渲染(见 aider/io.py),保证测试输出干净、不污染断言。
3. 测试端演化为 DummyInput / DummyOutput
实录最终采用 create_input(StringIO(''));而现仓库 tests/basic/test_main.py 的测试则更直接,统一使用 prompt_toolkit 的现成 Dummy 对象:
from prompt_toolkit.input import DummyInput
from prompt_toolkit.output import DummyOutput
见 tests/basic/test_main.py。典型调用形如:
main(["--no-git", "--exit", "--yes"], input=DummyInput(), output=DummyOutput())
见 tests/basic/test_main.py。DummyInput 不阻塞等待任何终端输入,天然适合"无输入/直接退出"的 CLI 用例。从历史方案到当前形态可以清晰看到同一条演进主线:把 I/O 从全局隐式状态(sys.stdin / 文件重定向)变成显式注入的参数,让测试彻底脱离真实终端。
4. 可测试性红利:面向脚本与自动化调用
input / output 注入带来的不仅是一两个测试用例的便利。return_coder=True、force_git_root 等新参数与句柄注入叠加后,aider 的 main() 已经可以作为一个"可编程函数"被其他 Python 代码直接驱动,这也是 scripting.md 介绍的用法。一次针对测试的改造,最终反哺出了项目级的脚本化接口——这是依赖注入式重构最常见的长期回报。
十、从这则实录提炼的通用工作方法
-
多文件改动先铺测试侧契约,再让报错牵引生产侧补齐。实录中测试先行调用
main(..., input=..., output=...),随后以 pytest 的 TypeError 为路标修改签名,是一种低成本、可验证的推进顺序。 -
粘贴报错与 API 签名,是最有效的两种调试输入。整段会话的收敛速度几乎与用户提供的证据质量成正比:第一次 TypeError 一步修复,而 API 参数名则经历了两次猜测——直到用户贴出
PromptSession构造签名才终结。 -
跑偏时果断
^C。中断不是失败,而是把预算从"无用的长回复"中省下来,用于"一条澄清 + 一条更准的回复"。 -
善用 git 保护与对话外编辑。aider 自动应用编辑、自动提交,并能在检测到工作区带外变更时主动暂停征询提交意见(
Commit before the chat proceeds [y/n/commit message]?),让每一次变更都留痕、可回滚。 -
把 I/O 句柄设计为显式参数。从
/dev/null→create_pipe_input→create_input(StringIO(''))→DummyInput/DummyOutput的演进,本质都是让程序"输入从哪来、输出到哪去"可被替换,从而获得脱离真实终端环境做自动化测试(乃至脚本化复用)的能力。
延伸阅读
- 了解 edit block 与会话排版约定:examples/README.md
- 同一合集中的其他实战实录:semantic-search-replace.md、add-test.md、update-docs.md
InputOutput类定义与PromptSession组装:aider/io.py- CLI 入口
main()的现代签名:aider/main.py - 无终端场景下的测试写法:tests/basic/test_main.py
- aider 的编程式(脚本化)调用方式:docs/scripting.md
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00