Coding Agent目前已经发布到npm,大家可以先行直接先体验一下最终会做成什么样。github地址:github.com 欢迎 Star 支持,npm地址@di-code/coding-agent - npm。直接
npm install -g @di-code/coding-agent即可安装使用
本篇文章是《从零开发一个 Coding Agent》系列第十二篇。在前两篇中,我们已经完成了 CLI 参数解析和 print 模式:
text
参数数组 -> runCli() -> Agent.prompt() -> 最终文本 -> stdout
print 模式适合人直接阅读,但脚本和其他程序往往需要更多信息。例如:
- Agent 什么时候开始工作?
- 模型正在生成什么内容?
- 是否执行了工具?
- 这次请求最后是成功、取消还是失败?
- 最终 transcript 是什么?
如果只输出最终文本,这些过程就全部丢失了。因此这一篇增加第二种输出模式:JSONL 模式。
JSONL(JSON Lines,按行排列的 JSON)不是一个巨大的 JSON 数组,而是"每行一个独立 JSON 对象":
text
{"version":1,"event":{"type":"agent_start"}}
{"version":1,"event":{"type":"turn_start"}}
{"version":1,"event":{"type":"agent_end","messages":[...]}}
完成后,程序还要真正成为一个可以从命令行启动的 Node.js 程序:读取 process.argv,把 stdout/stderr 接到真实终端,并通过 package 的 bin 字段提供 di-code 命令。
本篇仍然使用 Faux Provider。它只返回固定的 Faux response.,不访问网络、不读取 API Key,也不提前实现 read 工具、会话存储或真实 Provider。
JSON 模式与 print 模式的区别
两种模式都调用同一个 Agent,但观察目标不同:
| 模式 | 消费什么 | stdout 内容 |
|---|---|---|
最终 AssistantMessage |
最终 text block | |
| JSON | 每一个 AgentEvent |
一行一个版本化 JSON record |
可以把 Agent 想成一场比赛:
- print 模式只关心最后的比分;
- JSON 模式要记录发令、每一圈和比赛结束等完整过程。
因此不能把 JSON 模式实现成"调用 runPrintMode() 后再把文本包成 JSON"。那样会丢失 agent_start、message_update 和 agent_end 等事件。
为什么每一行都要带版本
最简单的 JSON 输出可能是:
json
{"event":{"type":"agent_start"}}
但未来我们可能增加字段、改变事件结构,或者新增事件类型。消费者拿到一行后,需要知道它遵循哪一版协议。因此本项目固定使用:
ts
export const JSON_EVENT_VERSION = 1 as const;
export interface JsonEventRecord {
readonly version: typeof JSON_EVENT_VERSION;
readonly event: AgentEvent;
}
输出对象的形状是:
ts
{
version: 1,
event: AgentEvent,
}
版本放在每一行,而不是只放首行,有两个好处:
- 消费者可以独立解析任意一行,不必依赖前面的状态。
- 日志被截断、分片或从中间开始读取时,仍然能识别协议版本。
这是一种小而重要的公共接口设计。JSONL 不是临时调试文本,而是 CLI 与脚本之间的通信契约。
一次 JSON 命令的数据流
下面是 --mode json hello 的完整路径:
这里要注意两个时间点:订阅必须发生在调用 prompt() 之前,否则开头事件可能已经发出;取消订阅必须放在 finally,否则成功、失败和 reject 三条路径会出现不同的清理行为。
第一步:实现 JSON 输出模式
创建文件:
text
di-code/packages/coding-agent/src/modes/json.ts
先导入 Agent 事件、助手消息和上一篇定义的 I/O:
ts
import type { AgentEvent, AgentListener } from "@di-code/agent";
import type { AssistantMessage } from "@di-code/ai";
import type { PrintIo } from "./print.ts";
AgentListener 的形状是接收一个 AgentEvent 的函数,并可以返回 Promise。JSON writer 本身是同步的,所以这里只需要把事件包装后交给 stdout。
然后定义协议版本和最小运行器接口:
ts
export const JSON_EVENT_VERSION = 1 as const;
export interface JsonEventRecord {
readonly version: typeof JSON_EVENT_VERSION;
readonly event: AgentEvent;
}
export interface JsonRunner {
prompt(text: string): Promise<AssistantMessage>;
subscribe(listener: AgentListener): () => void;
}
JsonRunner 比完整的 Agent 更小,但它比 PromptRunner 多了 subscribe()。这是因为 JSON 模式要观察过程事件,不能只等待最终消息。
把未知异常转成 Error
和 print 模式一样,外部 Promise 可能 reject 一个字符串、数字或普通对象。先统一为 Error:
ts
function toError(cause: unknown): Error {
return cause instanceof Error ? cause : new Error(String(cause));
}
实现 runJsonMode
继续在同一个文件中加入:
ts
export async function runJsonMode(prompt: string, runner: JsonRunner, io: PrintIo): Promise<number> {
const unsubscribe = runner.subscribe((event) => {
const record: JsonEventRecord = { version: JSON_EVENT_VERSION, event };
io.stdout(`${JSON.stringify(record)}\n`);
});
try {
const assistant = await runner.prompt(prompt);
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") {
io.stderr(`${assistant.errorMessage}\n`);
return 1;
}
return 0;
} catch (cause) {
io.stderr(`${toError(cause).message}\n`);
return 1;
} finally {
unsubscribe();
}
}
逐段看这段代码:
subscribe()放在try之前调用,并立即保存取消订阅函数。这样prompt()发出的第一个事件也不会丢失。- listener 每收到一个事件,就创建
{ version: 1, event },使用JSON.stringify()转成一行文本,并补上换行符。 prompt()返回失败消息时,已经产生的 JSONL 仍然保留在 stdout;错误说明另外写入 stderr,退出码返回1。prompt()reject 时同样只写 stderr,不把异常堆栈污染 JSONL。finally无论成功、结构化失败还是异常,都执行unsubscribe()。
为什么不把所有事件先收集起来
JSONL 的价值之一就是实时性。消费者可以在 Agent 仍然运行时读取第一行、第二行,而不用等整个请求结束。逐事件写出还可以降低内存占用,并保留事件发生顺序。
为什么失败事件仍留在 stdout
失败也可能已经产生了有用的生命周期事件,例如:
text
agent_start
turn_start
message_start
message_end(error)
agent_end
这些事件是合法的 AgentEvent,应该照常记录。stderr 只补充面向人的诊断文本,不能为了报错而清空或覆盖已经输出的 JSONL。
第二步:为 JSON 模式编写单元测试
创建:
text
di-code/packages/coding-agent/test/json.test.ts
这里使用 fake JsonRunner,专门测试输出投影,不重复测试 Agent Loop。
测试每个事件独占一行
测试 runner 可以在 prompt() 中手动触发订阅者:
ts
function createRunner(options: { message?: AssistantMessage; reject?: Error; events?: AgentEvent[] }) {
let listener: AgentListener | undefined;
const unsubscribe = vi.fn();
const runner: JsonRunner = {
subscribe(next) {
listener = next;
return unsubscribe;
},
async prompt() {
for (const event of options.events ?? []) {
await listener?.(event);
}
if (options.reject) {
throw options.reject;
}
return options.message ?? assistant("stop");
},
};
return { runner, unsubscribe };
}
然后验证每一行都能独立解析:
ts
const io = createIo();
const { runner } = createRunner({
events: [{ type: "agent_start" }, { type: "turn_start" }],
});
expect(await runJsonMode("hello", runner, io)).toBe(0);
const records = io.stdout.mock.calls.map(
([line]) => JSON.parse(line.trim()) as { version: number; event: AgentEvent },
);
expect(records).toHaveLength(2);
expect(records.every((record) => record.version === 1)).toBe(true);
expect(records.map((record) => record.event.type)).toEqual(["agent_start", "turn_start"]);
expect(io.stderr).not.toHaveBeenCalled();
这里的 JSON.parse() 比直接检查字符串更有意义,因为它证明消费者真的可以读取输出。
测试失败消息和 reject
失败消息的测试要同时断言 stdout 和 stderr:
ts
const io = createIo();
const { runner } = createRunner({
message: assistant("error", "model failed"),
events: [{ type: "agent_start" }],
});
expect(await runJsonMode("fail", runner, io)).toBe(1);
expect(io.stdout).toHaveBeenCalledTimes(1);
expect(io.stderr).toHaveBeenCalledWith("model failed\n");
再验证 Promise rejection 会清理订阅:
ts
const io = createIo();
const { runner, unsubscribe } = createRunner({ reject: new Error("listener failed") });
expect(await runJsonMode("reject", runner, io)).toBe(1);
expect(io.stderr).toHaveBeenCalledWith("listener failed\n");
expect(unsubscribe).toHaveBeenCalledTimes(1);
运行:
powershell
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run json.test.ts
在 GREEN 阶段应看到 1 个测试文件、3 个测试全部通过。
第三步:让 runMain 分派 JSON
上一篇的 runMain() 对 JSON 还返回占位错误。现在打开:
text
di-code/packages/coding-agent/src/main.ts
增加 import:
ts
import { runJsonMode } from "./modes/json.ts";
然后把 run 回调固定为"只创建一次运行时,再按模式选择输出层":
ts
run: async (command) => {
const faux = createFauxProvider({ responses: options.fauxResponses, now: options.now });
const agent = new Agent({ provider: faux.provider, model: faux.model, now: options.now });
if (command.mode === "json") {
return runJsonMode(command.prompt, agent, options);
}
return runPrintMode(command.prompt, agent, options);
},
这里不能复制 parseCliArgs(),也不能让 JSON 模式自己创建另一份 Agent。两种模式应该共享同一条 AgentSession 边界,只改变"怎样观察和输出结果"。
更新 main 集成测试
打开:
text
di-code/packages/coding-agent/test/main.test.ts
把 6b 中"JSON 尚未实现"的测试改成成功断言:
ts
it("runs a faux prompt through versioned JSON mode", async () => {
const io = createIo();
const exitCode = await runMain(["--mode", "json", "hello"], {
...io,
version: "0.0.0",
fauxResponses: [{ type: "success", content: [{ type: "text", text: "done" }] }],
});
expect(exitCode).toBe(0);
expect(io.stderr).not.toHaveBeenCalled();
const records = io.stdout.mock.calls.map(
([line]) => JSON.parse(line.trim()) as { version: number; event: { type: string } },
);
expect(records.length).toBeGreaterThan(0);
expect(records.every((record) => record.version === 1)).toBe(true);
expect(records.map((record) => record.event.type)).toContain("agent_start");
expect(records.map((record) => record.event.type)).toContain("agent_end");
});
运行回归测试:
powershell
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run main.test.ts
npm test --workspace packages/coding-agent -- --run print.test.ts
npm test --workspace packages/coding-agent -- --run cli.test.ts
预期分别为 3/3、4/4 和 8/8。这证明 JSON 接入没有破坏已有 print 和参数行为。
第四步:创建真正的 Node 入口
到现在为止,runMain() 仍然是一个可测试函数。用户直接运行程序时,还需要一个很薄的入口把 Node 全局对象接进来。
创建:
text
di-code/packages/coding-agent/src/entry.ts
写入:
ts
#!/usr/bin/env node
import packageMetadata from "../package.json" with { type: "json" };
import { runMain } from "./main.ts";
const exitCode = await runMain(process.argv.slice(2), {
version: packageMetadata.version,
fauxResponses: [{ type: "success", content: [{ type: "text", text: "Faux response." }] }],
stdout: (text) => process.stdout.write(text),
stderr: (text) => process.stderr.write(text),
});
process.exitCode = exitCode;
逐行理解:
- shebang 让 Unix 环境可以把生成后的文件当作脚本执行;Windows 仍由 Node/npm 负责启动。
process.argv.slice(2)去掉 Node 和脚本路径,只把用户参数交给runMain()。- package JSON 提供当前版本,不在 CLI 解析器中硬编码文件读取。
fauxResponses是当前无网络垂直切片的固定响应;它不是用户输入,也不是未来真实 Provider 的替代品。- writer 把模式层的
PrintIo接到真实 stdout/stderr。 process.exitCode设置退出状态,但不会立刻中断异步清理。
入口文件应该保持很薄。参数语法属于 cli.ts,模式行为属于 modes/,运行时组合属于 main.ts;entry.ts 只负责接线。
第五步:配置 package 命令和 bin
在根目录的 di-code/package.json 中加入:
json
"dev": "node --experimental-strip-types packages/coding-agent/src/entry.ts"
--experimental-strip-types 让当前 Node 版本直接运行 TypeScript 类型擦除后的源码,适合本学习项目的开发入口。它不会替代正式构建。
在 di-code/packages/coding-agent/package.json 中加入:
json
"bin": {
"di-code": "./dist/entry.js"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"dev": "node --experimental-strip-types src/entry.ts",
"test": "vitest run --passWithNoTests"
}
bin 的含义是:安装或链接这个 package 后,命令 di-code 指向构建产物 dist/entry.js。不要指向 src/entry.ts,因为发布和子进程测试使用的是构建后的 JavaScript。
为什么 dev 不自动 build
如果 dev 先执行 build,再启动程序,build 的输出可能混入机器正在消费的 stdout,而且每次运行都增加额外步骤。构建应该由独立的 npm run build 完成;开发 smoke test 再运行源码入口。
使用 npm 执行 JSON 命令时,建议加 --silent:
powershell
npm run --silent dev -- --mode json hello
普通 npm run dev 可能由 npm 自己打印脚本 banner。那不是应用输出,但会让逐行 JSON 消费者看到非 JSON 文本。直接运行 node dist/entry.js 或使用安装后的 di-code bin 也可以避免这个问题。
第六步:用真实子进程测试入口
直接调用 runMain() 只能证明函数组合正确,不能证明:
process.argv.slice(2)是否正确;- package JSON 是否能被入口加载;
process.exitCode是否传到了操作系统;- stdout/stderr 是否真的分离;
- 构建产物
dist/entry.js是否可以启动。
因此创建:
text
di-code/packages/coding-agent/test/cli-process.test.ts
测试使用 Node 的 spawn() 启动独立进程:
ts
const entryPath = resolve(process.cwd(), "dist/entry.js");
async function runCli(args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, [entryPath, ...args], {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => resolveResult({ code, stdout, stderr }));
});
}
然后覆盖五条外部行为:
ts
it("prints help without runtime diagnostics", async () => {
const result = await runCli(["--help"]);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Usage: di-code");
expect(result.stderr).toBe("");
});
it("prints the package version", async () => {
const result = await runCli(["--version"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("0.0.0\n");
expect(result.stderr).toBe("");
});
it("runs the deterministic print path", async () => {
const result = await runCli(["--print", "hello"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("Faux response.\n");
expect(result.stderr).toBe("");
});
it("writes versioned JSON events", async () => {
const result = await runCli(["--mode", "json", "hello"]);
expect(result.code).toBe(0);
expect(result.stderr).toBe("");
const records = result.stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { version: number; event: { type: string } });
expect(records.length).toBeGreaterThan(0);
expect(records.every((record) => record.version === 1)).toBe(true);
expect(records.map((record) => record.event.type)).toContain("agent_start");
expect(records.map((record) => record.event.type)).toContain("agent_end");
});
it("keeps usage errors off stdout", async () => {
const result = await runCli(["--unknown"]);
expect(result.code).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain('Unknown option "--unknown".');
});
这类测试比单元测试更接近用户实际体验,因为它验证的是完整的进程边界,而不是某个函数的返回值。
运行前必须先构建:
powershell
Set-Location D:\pi\di-code
npm run build
npm test --workspace packages/coding-agent -- --run cli-process.test.ts
预期子进程测试为 1 个文件、5 个测试全部通过。
最终验证与 smoke test
完成上述步骤后,运行:
powershell
Set-Location D:\pi\di-code
npm run build
npm test --workspace packages/coding-agent
npm run check
npm run dev -- --help
npm run dev -- --version
npm run dev -- --print hello
npm run --silent dev -- --mode json hello
预期结果:
- coding-agent 收集 5 个测试文件、23 个测试,全部通过。
- 根
npm run check和根npm run build成功。 - help 输出 Usage,退出码为
0,不需要凭据。 - version 输出
0.0.0。 - print stdout 只有
Faux response.。 - JSON stdout 的每个非空行都能独立
JSON.parse,且带有version: 1;其中包含agent_start和agent_end。 - 正常 JSON 运行的 stderr 为空。
检查 JSON 输出时,可以在 PowerShell 中这样观察行数:
powershell
$jsonLines = npm run --silent dev -- --mode json hello
$jsonLines | ForEach-Object { $_ | ConvertFrom-Json }
如果某一行不是 JSON,ConvertFrom-Json 会立即报错,这比肉眼查看长字符串可靠。
总结
这一篇完成了 CLI 的第一条完整产品链路:
runJsonMode()订阅 AgentEvent,把每个事件包装为{ version: 1, event }。- 每个 JSON record 独占一行,消费者可以逐行读取和解析。
- 结构化失败仍保留已经产生的 JSONL,诊断只写 stderr,退出码为
1。 finally中取消订阅,避免成功、失败和 reject 路径留下 listener。runMain()在同一个 Agent 上选择 print 或 JSON 输出,不复制 CLI 解析逻辑。entry.ts把process.argv、真实 stdout/stderr 和退出码接入应用。bin指向dist/entry.js,子进程测试验证了真实的命令行边界。
到这里,Task 6 的参数、print、JSONL 和开发入口已经连成一条确定性链路。它还不会读取文件,但已经具备了一个可被脚本消费、可通过事件观察、并且不依赖真实网络的 CLI 基础。
下一篇将进入 Task 7:实现受工作目录约束的 read 工具,并完成"模型请求 read -> 本地执行 -> 工具结果回送 -> 模型最终回答"的端到端流程。
开源地址:github.com