本篇文章是《从零开发一个 Coding Agent》系列第十一篇。在上一篇中,我们实现了 CLI 参数解析和静态命令,程序已经能够把下面的参数:
powershell
di-code --print "请解释这段代码"
解析成一个明确的命令对象:
ts
{
kind: "run",
mode: "print",
prompt: "请解释这段代码",
}
不过,上一篇的 runCli() 只负责解析和分派。它知道用户选择了 print 模式,却还不知道怎样创建 Agent、怎样发送 prompt,也不知道应该把模型返回的哪些内容写到终端。
这一篇将完成 print 模式。最终会形成下面这条可运行链路:
text
CLI 参数
-> runCli()
-> 创建 Faux Provider 和 Agent
-> Agent.prompt()
-> 得到最终 AssistantMessage
-> 只提取 text 内容
-> 成功写 stdout,失败写 stderr
本篇仍然不接入真实大模型。我们使用 Faux Provider 提供确定的模型响应,因此不需要网络和 API Key,也不会产生费用。
print 模式到底是什么
print 模式可以理解为"只打印最终答案"的非交互模式。它适合在终端中直接调用,也适合被其他脚本组合:
powershell
di-code --print "生成一条提交信息"
假设模型最终返回:
text
feat: add CLI print mode
那么 stdout 中应该只有:
text
feat: add CLI print mode
不应该混入调试日志、思考过程、工具调用结构或错误提示。这样用户才能安全地把结果通过管道传给其他程序。
例如:
powershell
di-code --print "生成文件名" | Set-Content result.txt
如果程序把 Loading model...、错误堆栈或 thinking 一起写入 stdout,result.txt 就不再是纯净的最终结果。
因此,print 模式有三个重要约束:
- 成功时,stdout 只包含最终 AssistantMessage 中的文本。
- 失败时,stdout 保持为空,诊断写入 stderr。
- 成功返回退出码
0,失败返回退出码1。
为什么不边生成边打印
大模型本来就是流式返回的。假设模型准备回答 hello world,中间可能产生:
text
text_delta("hello ")
text_delta("world")
done
看起来,我们可以每收到一个 text_delta 就立即写 stdout。但是 print 模式的契约是"最终文本",逐段输出会带来一个问题:如果模型输出 hello 后请求失败,stdout 中会留下半截答案。
text
hello
调用它的脚本无法判断这究竟是完整答案,还是失败前留下的残片。
所以本项目固定采用以下方式:
text
Agent 内部继续处理流事件
|
v
等待本轮完整结束
|
v
取得最终 AssistantMessage
|
v
成功后一次性写 stdout
Agent 仍然可以在内部消费 message_update、执行工具和维护 transcript,但 print 模式只关心最终返回的 AssistantMessage。
stdout、stderr 和退出码
命令行程序通常有两个输出通道:
| 通道 | 本篇用途 |
|---|---|
| stdout(标准输出) | 成功的最终文本 |
| stderr(标准错误) | 参数错误、模型失败、取消和异常诊断 |
退出码则表示命令整体是否成功:
| 退出码 | 含义 |
|---|---|
0 |
成功完成 |
1 |
用户输入错误、模式不可用或运行失败 |
例如模型失败时,我们希望得到:
text
stdout: ""
stderr: "model failed\n"
exit: 1
把结果和诊断分开后,人在终端中能看到错误,脚本也能通过退出码和输出通道准确判断状态。
本篇的数据流
下面是 print 命令从参数到输出的完整路径:
这条链路分成三层:
runCli()负责参数、help/version 和命令分派。runMain()负责组合 Provider、Agent 和具体输出模式。runPrintMode()只负责把最终消息投影到 stdout/stderr。
每一层只处理自己的问题,后续加入 JSON 模式时不需要复制参数解析或 Agent Loop。
第一步:加入 workspace 依赖
coding-agent 现在需要创建 Agent 和 Faux Provider,因此要在下面的文件中声明直接依赖:
text
di-code/packages/coding-agent/package.json
加入:
json
"dependencies": {
"@di-code/agent": "0.0.0",
"@di-code/ai": "0.0.0"
}
这里两个包都必须直接声明:
@di-code/agent提供Agent。@di-code/ai提供AssistantMessage、createFauxProvider()和FauxResponse。
不要因为 agent 自己依赖 ai,就省略 coding-agent 对 ai 的声明。只要代码直接 import 了一个包,就应该把它列为直接依赖。
修改后在 PowerShell 中更新 lockfile:
powershell
Set-Location D:\pi\di-code
npm install --workspace @di-code/coding-agent --package-lock-only --ignore-scripts
--package-lock-only 表示只同步依赖记录,--ignore-scripts 防止执行依赖包的生命周期脚本。
第二步:定义 print 模式的最小接口
创建文件:
text
di-code/packages/coding-agent/src/modes/print.ts
首先导入最终助手消息类型,并定义两个小接口:
ts
import type { AssistantMessage } from "@di-code/ai";
export interface PrintIo {
stdout(text: string): void;
stderr(text: string): void;
}
export interface PromptRunner {
prompt(text: string): Promise<AssistantMessage>;
}
PrintIo 有什么用
我们没有在 runPrintMode() 中直接调用:
ts
process.stdout.write(...);
process.stderr.write(...);
而是把两个写入函数作为参数传进来。生产环境可以把它们连接到真实终端,测试则可以传入 vi.fn(),准确检查写了什么。
PromptRunner 为什么不直接写成 Agent
print 模式真正需要的能力只有一个:
ts
prompt(text): Promise<AssistantMessage>
它不需要知道 Provider 是谁,也不需要读取 Agent 的 transcript、工具列表或订阅事件。因此这里使用最小的 PromptRunner 接口。
真实的 Agent 已经具有同样的 prompt() 方法,所以它可以直接传入;单元测试也可以使用一个只有 prompt() 的 fake 对象。
这就是依赖倒置最直观的例子:print 模式依赖"它真正需要的能力",而不是依赖一个庞大的具体对象。
第三步:理解 AssistantMessage 的两种结果
Agent.prompt() 返回 AssistantMessage。这个类型包含成功和失败两种形态。
成功消息可能是:
ts
{
role: "assistant",
content: [{ type: "text", text: "hello" }],
stopReason: "stop",
// provider、model、usage、timestamp 等字段
}
失败消息可能是:
ts
{
role: "assistant",
content: [],
stopReason: "error",
errorMessage: "model failed",
// provider、model、usage、timestamp 等字段
}
取消也是失败消息的一种:
ts
{
stopReason: "aborted",
errorMessage: "request cancelled",
// 其他字段
}
这里很容易产生误解:模型失败不一定让 Agent.prompt() 抛异常。Provider 的正常协议可以返回一条 stopReason: "error" 的结构化消息。只有监听器失败、内部状态不满足约束等异常路径,prompt() 才会 reject。
因此 print 模式必须处理两类失败:
text
await runner.prompt(prompt)
|
+-> 正常返回,但 stopReason 是 error/aborted
|
+-> Promise 直接 reject
只写 try/catch 而不检查 stopReason,会把结构化失败误当成成功。
第四步:只提取 text 内容块
AssistantMessage 的 content 不一定全是普通文本。它还可能包含 thinking 或 tool call:
ts
[
{ type: "thinking", thinking: "先分析问题" },
{ type: "text", text: "hello " },
{ type: "text", text: "world" },
]
print 模式只应该输出:
text
hello world
加入 textContent():
ts
function textContent(message: AssistantMessage): string {
return message.content
.filter(
(content): content is Extract<AssistantMessage["content"][number], { type: "text" }> =>
content.type === "text",
)
.map((content) => content.text)
.join("");
}
这段代码分三步工作:
filter()只保留type === "text"的内容块。- 类型谓词告诉 TypeScript:过滤后的
content一定具有text字段。 map()取出每段文本,join("")按原顺序拼接。
不要对整个 content 调用 JSON.stringify()。thinking 是模型内部推理内容,tool call 是结构化控制信息,它们都不应该伪装成面向用户的最终答案。
第五步:实现 runPrintMode
先加入一个小工具,把未知异常统一转换成 Error:
ts
function toError(cause: unknown): Error {
return cause instanceof Error ? cause : new Error(String(cause));
}
然后实现 print 模式的主函数:
ts
export async function runPrintMode(prompt: string, runner: PromptRunner, io: PrintIo): Promise<number> {
try {
const assistant = await runner.prompt(prompt);
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") {
io.stderr(`${assistant.errorMessage}\n`);
return 1;
}
const text = textContent(assistant);
if (text.length > 0) {
io.stdout(`${text}\n`);
}
return 0;
} catch (cause) {
io.stderr(`${toError(cause).message}\n`);
return 1;
}
}
它的控制流程可以概括为:
text
调用 prompt
|
+-> 返回 error/aborted 消息 -> stderr -> 1
|
+-> 返回成功消息 -> 提取 text -> stdout -> 0
|
+-> Promise reject -> catch -> stderr -> 1
成功但没有 text block 时,函数返回 0,同时不写空行。这样 stdout 仍然表示"模型实际给出的最终文本",不会凭空增加内容。
第六步:测试 print 输出投影
创建测试文件:
text
di-code/packages/coding-agent/test/print.test.ts
这里不需要真实 Agent。我们要测试的是"AssistantMessage 怎样变成输出",所以使用 fake PromptRunner 更直接。
先准备 I/O 和 runner:
ts
function createIo(): PrintIo {
return { stdout: vi.fn(), stderr: vi.fn() };
}
function createRunner(message: AssistantMessage): PromptRunner {
return { prompt: vi.fn(async () => message) };
}
测试只输出 text
ts
const io = createIo();
const runner = createRunner(
successfulMessage([
{ type: "thinking", thinking: "hidden" },
{ type: "text", text: "hello " },
{ type: "text", text: "world" },
]),
);
expect(await runPrintMode("say hello", runner, io)).toBe(0);
expect(runner.prompt).toHaveBeenCalledWith("say hello");
expect(io.stdout).toHaveBeenCalledWith("hello world\n");
expect(io.stderr).not.toHaveBeenCalled();
这个测试同时证明了三件事:prompt 被原样转发,thinking 被隐藏,多段 text 按顺序拼接。
测试结构化失败
ts
const io = createIo();
const runner = createRunner(failedMessage("error", "model failed"));
expect(await runPrintMode("fail", runner, io)).toBe(1);
expect(io.stdout).not.toHaveBeenCalled();
expect(io.stderr).toHaveBeenCalledWith("model failed\n");
aborted 也要用同样的方式测试,确保取消不会污染 stdout。
测试 Promise rejection
ts
const io = createIo();
const runner: PromptRunner = {
prompt: vi.fn(async () => {
throw new Error("listener failed");
}),
};
expect(await runPrintMode("reject", runner, io)).toBe(1);
expect(io.stdout).not.toHaveBeenCalled();
expect(io.stderr).toHaveBeenCalledWith("listener failed\n");
运行定向测试:
powershell
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run print.test.ts
当前应收集 1 个测试文件,共 4 个测试,四个测试全部通过。
第七步:用 runMain 组合完整链路
runPrintMode() 只认识 PromptRunner,它不会自己创建 Provider 和 Agent。我们还需要一个组合入口把各层接起来。
创建:
text
di-code/packages/coding-agent/src/main.ts
完整实现如下:
ts
import { Agent } from "@di-code/agent";
import { createFauxProvider, type FauxResponse } from "@di-code/ai";
import { type CliDependencies, runCli } from "./cli.ts";
import { type PrintIo, runPrintMode } from "./modes/print.ts";
export interface MainOptions extends PrintIo {
readonly version: string;
readonly fauxResponses: readonly FauxResponse[];
readonly now?: () => number;
}
export async function runMain(args: readonly string[], options: MainOptions): Promise<number> {
const dependencies: CliDependencies = {
stdout: options.stdout,
stderr: options.stderr,
version: options.version,
run: async (command) => {
if (command.mode === "json") {
options.stderr("JSON mode is not available until Task 6c.\n");
return 1;
}
const faux = createFauxProvider({ responses: options.fauxResponses, now: options.now });
const agent = new Agent({ provider: faux.provider, model: faux.model, now: options.now });
return runPrintMode(command.prompt, agent, options);
},
};
return runCli(args, dependencies);
}
runMain() 没有重新解析参数,而是继续调用上一篇的 runCli()。当命令是 help 或 version 时,runCli() 会直接返回;只有 kind: "run" 才会调用这里注入的 run 回调。
为什么 Provider 要延迟创建
注意,Faux Provider 和 Agent 都是在 run 回调内部创建的:
ts
run: async (command) => {
// 进入这里以后,才创建 Provider 和 Agent
}
如果在 runMain() 一开始就创建运行时,那么 --help 也会执行 Provider 初始化。未来换成真实 Provider 后,这可能提前读取凭据、配置文件甚至访问外部资源。
延迟创建保证了静态命令始终轻量:
text
--help / --version
|
v
runCli 直接返回
|
X 不创建 Provider
X 不创建 Agent
X 不读取凭据
JSON 判断也放在 Provider 创建之前。因为本篇还没有实现 JSON 模式,程序会明确写入:
text
JSON mode is not available until Task 6c.
然后返回 1,而不是偷偷走 print 模式,让用户误以为自己得到了 JSON。
第八步:使用 Faux Provider 做集成测试
创建:
text
di-code/packages/coding-agent/test/main.test.ts
这一次不再使用 fake PromptRunner,而是通过 runMain() 走过真实的 runCli -> Faux Provider -> Agent -> runPrintMode 链路。
成功测试如下:
ts
const io = { stdout: vi.fn(), stderr: vi.fn() };
const exitCode = await runMain(["--print", "hello"], {
...io,
version: "0.0.0",
fauxResponses: [
{
type: "success",
content: [{ type: "text", text: "done" }],
},
],
});
expect(exitCode).toBe(0);
expect(io.stdout).toHaveBeenCalledWith("done\n");
expect(io.stderr).not.toHaveBeenCalled();
它证明的不是一个孤立函数,而是整条链路:CLI 把 prompt 解析出来,Faux Provider 生成标准事件,Agent 得到最终消息,print 模式最后输出 done。
还需要测试 JSON 边界:
ts
const exitCode = await runMain(["--mode", "json", "hello"], {
...io,
version: "0.0.0",
fauxResponses: [
{
type: "success",
content: [{ type: "text", text: "must not run" }],
},
],
});
expect(exitCode).toBe(1);
expect(io.stdout).not.toHaveBeenCalled();
expect(io.stderr).toHaveBeenCalledWith("JSON mode is not available until Task 6c.\n");
最后检查 help 不需要运行时。测试可以传入空响应队列:
ts
const exitCode = await runMain(["--help"], {
...io,
version: "0.0.0",
fauxResponses: [],
});
expect(exitCode).toBe(0);
expect(io.stdout.mock.calls[0]?.[0]).toContain("Usage: di-code");
expect(io.stderr).not.toHaveBeenCalled();
如果 help 错误地创建并调用了 Faux Provider,空响应队列会导致失败。因此这个测试也间接证明了运行时没有被触碰。
运行集成测试:
powershell
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run main.test.ts
当前应收集 1 个测试文件,共 3 个测试,三个测试全部通过。
完整验证
先分别运行三个最小测试集合:
powershell
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run print.test.ts
npm test --workspace packages/coding-agent -- --run main.test.ts
npm test --workspace packages/coding-agent -- --run cli.test.ts
预期结果分别是:
text
print.test.ts -> 1 file / 4 tests passed
main.test.ts -> 1 file / 3 tests passed
cli.test.ts -> 1 file / 8 tests passed
然后运行 coding-agent 包内全部测试、根质量检查和构建:
powershell
npm test --workspace packages/coding-agent
npm run check
npm run build --workspace @di-code/coding-agent
coding-agent 当前应有 3 个测试文件、15 个测试全部通过。npm run check 应没有 Biome 或 TypeScript 错误,构建应成功生成 packages/coding-agent/dist。
不要手工修改 dist,它只由构建脚本生成。
总结
这一篇让上一章的 CLI 参数真正连接到了 Agent,并完成了第一种输出模式:
PromptRunner把 print 模式限制在最小的prompt()能力上。PrintIo把 stdout/stderr 与全局进程隔离,方便测试和后续组合。runPrintMode()等待最终 AssistantMessage,只输出其中的 text block。error、aborted和 rejected Promise 都写 stderr,并返回退出码1。runMain()复用runCli(),并延迟创建 Faux Provider 和 Agent。- help/version 不进入运行时,尚未实现的 JSON 模式也会明确失败。
- 单元测试验证消息投影,集成测试验证 CLI 到 Faux Agent 的完整链路。
现在,我们已经可以在不访问真实网络的情况下证明:一个 print 命令能够经过参数解析、模型事件流和 Agent Loop,最后得到干净的文本结果。
下一步将实现 JSON 模式。它不会只保留最终文本,而是把 Agent 的生命周期事件包装成版本化 JSON,并保证 stdout 的每一行都是独立、合法的 JSON 对象。