← 返回AI教程
🌐 其他

从零开发一个 Coding Agent(十二):实现版本化 JSONL 与真实 CLI 入口

来源:掘金 · 发布于 2026-08-20 21:13:43
从零开发一个 Coding A
本篇文章是《从零开发一个 Coding Agent》系列第十二篇。在前两篇中,我们已经完成了 CLI 参数解析和 print 模式: print 模式适合人直接阅读,但脚本和其他程序往往需要更多信息。

从零开发一个 Coding Agent(十二):实现版本化 JSONL 与真实 CLI 入口

东方小月 2026-08-20 0 阅读5分钟

Coding Agent目前已经发布到npm,大家可以先行直接先体验一下最终会做成什么样。github地址:github.com 欢迎 Star 支持,npm地址@di-code/coding-agent - npm。直接npm install -g @di-code/coding-agent即可安装使用

本篇文章是《从零开发一个 Coding Agent》系列第十二篇。在前两篇中,我们已经完成了 CLI 参数解析和 print 模式:

参数数组 -> runCli() -> Agent.prompt() -> 最终文本 -> stdout

print 模式适合人直接阅读,但脚本和其他程序往往需要更多信息。例如:

  • Agent 什么时候开始工作?
  • 模型正在生成什么内容?
  • 是否执行了工具?
  • 这次请求最后是成功、取消还是失败?
  • 最终 transcript 是什么?

如果只输出最终文本,这些过程就全部丢失了。因此这一篇增加第二种输出模式:JSONL 模式。

JSONL(JSON Lines,按行排列的 JSON)不是一个巨大的 JSON 数组,而是“每行一个独立 JSON 对象”:

{"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 内容
print最终 AssistantMessage最终 text block
JSON每一个 AgentEvent一行一个版本化 JSON record

可以把 Agent 想成一场比赛:

  • print 模式只关心最后的比分;
  • JSON 模式要记录发令、每一圈和比赛结束等完整过程。

因此不能把 JSON 模式实现成“调用 runPrintMode() 后再把文本包成 JSON”。那样会丢失 agent_start、message_update 和 agent_end 等事件。

为什么每一行都要带版本

最简单的 JSON 输出可能是:

{"event":{"type":"agent_start"}}

但未来我们可能增加字段、改变事件结构,或者新增事件类型。消费者拿到一行后,需要知道它遵循哪一版协议。因此本项目固定使用:

export const JSON_EVENT_VERSION = 1 as const;

export interface JsonEventRecord {
	readonly version: typeof JSON_EVENT_VERSION;
	readonly event: AgentEvent;
}

输出对象的形状是:

{
	version: 1,
	event: AgentEvent,
}

版本放在每一行,而不是只放首行,有两个好处:

  1. 消费者可以独立解析任意一行,不必依赖前面的状态。
  2. 日志被截断、分片或从中间开始读取时,仍然能识别协议版本。

这是一种小而重要的公共接口设计。JSONL 不是临时调试文本,而是 CLI 与脚本之间的通信契约。

一次 JSON 命令的数据流

下面是 --mode json hello 的完整路径:

sequenceDiagram
	participant OS as 操作系统
	participant Entry as entry.ts
	participant Main as runMain
	participant CLI as runCli
	participant Agent as Agent
	participant Json as runJsonMode

	OS->>Entry: process.argv / stdout / stderr
	Entry->>Main: 参数和 I/O writer
	Main->>CLI: run 回调
	CLI-->>Main: { kind: 'run', mode: 'json' }
	Main->>Agent: 创建 Faux Provider + Agent
	Main->>Json: prompt + Agent + writer
	Json->>Agent: subscribe(listener)
	Json->>Agent: prompt("hello")
	Agent-->>Json: AgentEvent 1, 2, ...
	Json-->>OS: 每个事件一行 JSON
	Agent-->>Json: AssistantMessage
	Json-->>OS: 返回退出码 0 或 1
	Json->>Agent: finally unsubscribe()

这里要注意两个时间点:订阅必须发生在调用 prompt() 之前,否则开头事件可能已经发出;取消订阅必须放在 finally,否则成功、失败和 reject 三条路径会出现不同的清理行为。

第一步:实现 JSON 输出模式

创建文件:

di-code/packages/coding-agent/src/modes/json.ts

先导入 Agent 事件、助手消息和上一篇定义的 I/O:

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。

然后定义协议版本和最小运行器接口:

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:

function toError(cause: unknown): Error {
	return cause instanceof Error ? cause : new Error(String(cause));
}

实现 runJsonMode

继续在同一个文件中加入:

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)} `);
	});

	try {
		const assistant = await runner.prompt(prompt);
		if (assistant.stopReason === "error" || assistant.stopReason === "aborted") {
			io.stderr(`${assistant.errorMessage} `);
			return 1;
		}
		return 0;
	} catch (cause) {
		io.stderr(`${toError(cause).message} `);
		return 1;
	} finally {
		unsubscribe();
	}
}

逐段看这段代码:

  1. subscribe() 放在 try 之前调用,并立即保存取消订阅函数。这样 prompt() 发出的第一个事件也不会丢失。
  2. listener 每收到一个事件,就创建 { version: 1, event },使用 JSON.stringify() 转成一行文本,并补上换行符。
  3. prompt() 返回失败消息时,已经产生的 JSONL 仍然保留在 stdout;错误说明另外写入 stderr,退出码返回 1。
  4. prompt() reject 时同样只写 stderr,不把异常堆栈污染 JSONL。
  5. finally 无论成功、结构化失败还是异常,都执行 unsubscribe()。

为什么不把所有事件先收集起来

JSONL 的价值之一就是实时性。消费者可以在 Agent 仍然运行时读取第一行、第二行,而不用等整个请求结束。逐事件写出还可以降低内存占用,并保留事件发生顺序。

为什么失败事件仍留在 stdout

失败也可能已经产生了有用的生命周期事件,例如:

agent_start
turn_start
message_start
message_end(error)
agent_end

这些事件是合法的 AgentEvent,应该照常记录。stderr 只补充面向人的诊断文本,不能为了报错而清空或覆盖已经输出的 JSONL。

第二步:为 JSON 模式编写单元测试

创建:

di-code/packages/coding-agent/test/json.test.ts

这里使用 fake JsonRunner,专门测试输出投影,不重复测试 Agent Loop。

测试每个事件独占一行

测试 runner 可以在 prompt() 中手动触发订阅者:

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 };
}

然后验证每一行都能独立解析:

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:

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 ");

再验证 Promise rejection 会清理订阅:

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 ");
expect(unsubscribe).toHaveBeenCalledTimes(1);

运行:

Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run json.test.ts

在 GREEN 阶段应看到 1 个测试文件、3 个测试全部通过。

第三步:让 runMain 分派 JSON

上一篇的 runMain() 对 JSON 还返回占位错误。现在打开:

di-code/packages/coding-agent/src/main.ts

增加 import:

import { runJsonMode } from "./modes/json.ts";

然后把 run 回调固定为“只创建一次运行时,再按模式选择输出层”:

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 集成测试

打开:

di-code/packages/coding-agent/test/main.test.ts

把 6b 中“JSON 尚未实现”的测试改成成功断言:

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");
});

运行回归测试:

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 全局对象接进来。

创建:

di-code/packages/coding-agent/src/entry.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 中加入:

"dev": "node --experimental-strip-types packages/coding-agent/src/entry.ts"

--experimental-strip-types 让当前 Node 版本直接运行 TypeScript 类型擦除后的源码,适合本学习项目的开发入口。它不会替代正式构建。

在 di-code/packages/coding-agent/package.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:

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 是否可以启动。

因此创建:

di-code/packages/coding-agent/test/cli-process.test.ts

测试使用 Node 的 spawn() 启动独立进程:

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 }));
	});
}

然后覆盖五条外部行为:

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 ");
	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. ");
	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(" ")
		.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".');
});

这类测试比单元测试更接近用户实际体验,因为它验证的是完整的进程边界,而不是某个函数的返回值。

运行前必须先构建:

Set-Location D:\pi\di-code
npm run build
npm test --workspace packages/coding-agent -- --run cli-process.test.ts

预期子进程测试为 1 个文件、5 个测试全部通过。

最终验证与 smoke test

完成上述步骤后,运行:

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 中这样观察行数:

$jsonLines = npm run --silent dev -- --mode json hello
$jsonLines | ForEach-Object { $_ | ConvertFrom-Json }

如果某一行不是 JSON,ConvertFrom-Json 会立即报错,这比肉眼查看长字符串可靠。

总结

这一篇完成了 CLI 的第一条完整产品链路:

  1. runJsonMode() 订阅 AgentEvent,把每个事件包装为 { version: 1, event }。
  2. 每个 JSON record 独占一行,消费者可以逐行读取和解析。
  3. 结构化失败仍保留已经产生的 JSONL,诊断只写 stderr,退出码为 1。
  4. finally 中取消订阅,避免成功、失败和 reject 路径留下 listener。
  5. runMain() 在同一个 Agent 上选择 print 或 JSON 输出,不复制 CLI 解析逻辑。
  6. entry.ts 把 process.argv、真实 stdout/stderr 和退出码接入应用。
  7. bin 指向 dist/entry.js,子进程测试验证了真实的命令行边界。

到这里,Task 6 的参数、print、JSONL 和开发入口已经连成一条确定性链路。它还不会读取文件,但已经具备了一个可被脚本消费、可通过事件观察、并且不依赖真实网络的 CLI 基础。

下一篇将进入 Task 7:实现受工作目录约束的 read 工具,并完成“模型请求 read -> 本地执行 -> 工具结果回送 -> 模型最终回答”的端到端流程。

开源地址:github.com