从零开发一个 Coding Agent(十一):实现 CLI 的 print 模式

本篇文章是《从零开发一个 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 模式有三个重要约束:

  1. 成功时,stdout 只包含最终 AssistantMessage 中的文本。
  2. 失败时,stdout 保持为空,诊断写入 stderr。
  3. 成功返回退出码 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 命令从参数到输出的完整路径:

sequenceDiagram participant User as 用户 participant Main as runMain participant CLI as runCli participant Provider as Faux Provider participant Agent as Agent participant Print as runPrintMode User->>Main: ["--print", "hello"] Main->>CLI: 参数 + run 回调 CLI-->>Main: run 命令 Main->>Provider: 创建脚本化 Provider Main->>Agent: 创建 Agent Main->>Print: prompt + Agent + I/O Print->>Agent: prompt("hello") Agent->>Provider: 请求模型响应 Provider-->>Agent: 流事件和最终消息 Agent-->>Print: AssistantMessage alt 成功 Print-->>User: text -> stdout,return 0 else 失败或取消 Print-->>User: errorMessage -> stderr,return 1 end

这条链路分成三层:

  • 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 提供 AssistantMessagecreateFauxProvider()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("");
}

这段代码分三步工作:

  1. filter() 只保留 type === "text" 的内容块。
  2. 类型谓词告诉 TypeScript:过滤后的 content 一定具有 text 字段。
  3. 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,并完成了第一种输出模式:

  1. PromptRunner 把 print 模式限制在最小的 prompt() 能力上。
  2. PrintIo 把 stdout/stderr 与全局进程隔离,方便测试和后续组合。
  3. runPrintMode() 等待最终 AssistantMessage,只输出其中的 text block。
  4. erroraborted 和 rejected Promise 都写 stderr,并返回退出码 1
  5. runMain() 复用 runCli(),并延迟创建 Faux Provider 和 Agent。
  6. help/version 不进入运行时,尚未实现的 JSON 模式也会明确失败。
  7. 单元测试验证消息投影,集成测试验证 CLI 到 Faux Agent 的完整链路。

现在,我们已经可以在不访问真实网络的情况下证明:一个 print 命令能够经过参数解析、模型事件流和 Agent Loop,最后得到干净的文本结果。

下一步将实现 JSON 模式。它不会只保留最终文本,而是把 Agent 的生命周期事件包装成版本化 JSON,并保证 stdout 的每一行都是独立、合法的 JSON 对象。

相关推荐
__zRainy__2 小时前
Node系列 · Node基础:ES 模块化
node.js
深念Y3 小时前
Opencode Event 表写入优化方案
数据库·人工智能·ai·node.js·bug·优化·opencode
ikun778g5 小时前
DeepSeek Harness 本地部署保姆级教程:从 Node.js 24.0.0 安装到 WorkBuddy 一键运行
ai·node.js
浮生望14 小时前
Next.js App Router 实战入门:从 SPA 到 SSR 的全栈思维转变
全栈
烂蜻蜓18 小时前
Node.js入门教程(二十三):全局对象
node.js·编辑器·vim
xywww1681 天前
Node.js Claude API 实战接入:SDK 调用 Opus 5、环境变量配置与报错排查
node.js
weixin_431600441 天前
NestJS 入门(7):生命周期钩子——构造函数和 `OnModuleInit` 差在哪?
前端·后端·学习·node.js·nest.js
深念Y1 天前
基于 NapCat 与本地 RAG 的群聊 AI 机器人方案(ARM64 部署)
人工智能·ai·机器人·node.js·自动化·情感陪伴·bot
__zRainy__1 天前
Node系列 · Node基础:Node.js 概述
后端·node.js