Skip to content

2.9 错误处理与测试基础 ​

本章解决什么问题:程序总会失败——模型服务返回 500、要读的文件不存在、模型吐出的 JSON 少了个花括号。本章回答两个问题:失败发生时,这个消息怎么传给能处理它的那一层(throw / try / catch / finally、Error 与自定义错误);以及你怎么知道自己写的失败处理真的有效(自动化测试)。这是第二部分的最后一章,读完你就有了读 Pi 源码所需的全部 TypeScript 基础。

前置知识:2.4 泛型、class 与类型收窄(class、instanceof、unknown 的收窄流程)、2.5 异步编程:Promise 与 async/await(await 处的失败以异常形式出现)。

学习目标:读完本章后你能

  • 说清 throw 为什么是「第二条返回通道」,以及 try / catch / finally 三块各自负责什么;
  • 说出 Error 对象的四个关键字段(name / message / stack / cause),并写出一个带错误码的自定义错误 class;
  • 解释为什么 catch (e) 里的 e 是 unknown,并按固定流程用 instanceof 收窄它;
  • 分辨「被 try/catch 接住的异步失败」和「因为忘了 await 而无人接住的失败」,并说出后者的后果;
  • 用 Node.js 内置的 node:test + node:assert 写出可运行的测试,读懂 describe / it / assert.equal / assert.throws,并说出「测试即文档」是什么意思。

建立直觉:失败需要第二条返回通道 ​

一个函数只有一条返回通道:return。如果失败也必须走这条通道,你只有两个选择,而且都不好。

一是哨兵值:约定用某个特殊返回值代表失败,比如查找失败返回 -1、解析失败返回 null。问题在于哨兵值可以被无声忽略——调用方少写一个 if,程序就带着一个 null 继续往下跑,直到几十行之后才在某个毫不相干的地方崩掉,那时早已看不出真正的原因。

二是把成功和失败都塞进返回值,比如返回 { ok: true, value } | { ok: false, error }(2.4 见过的 Result 类型就是这个思路)。这个办法很稳妥,代价是每一层调用都必须亲手检查并转发:五层深的调用链,中间三层明明什么都不想管,也得逐层写「如果失败就原样返回失败」。

throw 提供的是第三条路,也就是第二条返回通道:函数遇到无法继续的情况时 throw 一个值,这个值不走 return,而是沿着调用栈自动向上冒泡,逐层退出正在执行的函数,直到遇见第一个 catch。中间那些不关心失败的层不需要写任何代码,失败自己会穿过它们。而且如果一路上没有任何人接住,程序会带着完整的错误信息和调用栈直接终止——失败不可能被无声忽略,这是 throw 相对哨兵值最大的价值。

📘 概念异常(Exception)
被 throw 抛出、沿调用栈向上传播的值。JavaScript 里任何值都能被 throw(字符串、数字都行),但约定俗成只 throw Error 对象——因为只有 Error 携带调用栈信息。后面会看到,「任何值都能被 throw」这条规则正是 catch 里的类型是 unknown 的原因。

try / catch / finally:三块的分工 ​

语法只有三块,职责各不相同:

ts
function half(n: number): number {
  if (n % 2 !== 0) {
    throw new Error(`${n} 不是偶数`);
  }
  return n / 2;
}

for (const n of [8, 7]) {
  try {
    console.log(`try:half(${n}) = ${half(n)}`);
  } catch (e) {
    console.log(`catch:${e instanceof Error ? e.message : String(e)}`);
  } finally {
    console.log(`finally:收拾现场(${n})`);
  }
}

真实输出:

try:half(8) = 4
finally:收拾现场(8)
catch:7 不是偶数
finally:收拾现场(7)
  • try 块圈出「这段代码可能失败」。注意 n = 7 那一轮,console.log 那行根本没有打印——half(7) 抛出后,try 块里剩下的代码全部被跳过,控制权直接交给 catch。这是 throw 与 return 的关键差别:它不是「返回一个坏值继续走」,而是「立刻离开」。
  • catch 块接住抛出的值并决定怎么办:记日志、换个方案重试、包装成更有意义的错误再抛出去、或者干脆咽下。咽下要慎重——一个空的 catch {} 会让失败彻底消失,是最难查的 bug 来源之一。
  • finally 块无论 try 成功、catch 接住失败、甚至 try 里 return 了,都一定会执行。它专门用来收拾现场:关文件、清计时器、把「正在运行」的标志位改回 false。上面输出里两次「收拾现场」,一次来自成功、一次来自失败——这正是 finally 存在的理由:把释放资源的代码只写一遍,而不是在成功路径和每个失败路径上各抄一份。

Error:不只是一段文字 ​

Error 是 JavaScript 内置的 class(2.4 讲过 class 在运行时真实存在,所以能用 instanceof 判断)。它有四个字段值得认识:

ts
const err = new Error("读取文件失败", { cause: new Error("ENOENT: no such file") });
console.log(`name    = ${err.name}`);
console.log(`message = ${err.message}`);
console.log(`cause   = ${err.cause instanceof Error ? err.cause.message : "无"}`);
console.log(`stack 第一行 = ${err.stack?.split("\n")[0]}`);

真实输出:

name    = Error
message = 读取文件失败
cause   = ENOENT: no such file
stack 第一行 = Error: 读取文件失败
  • message:给人看的一句话说明。
  • name:错误的种类名,默认 "Error";自定义错误里应该改掉它,这样打印出来能一眼看出是哪类错误。
  • stack:调用栈快照,记录了「错误在哪一行产生、是被谁调用的」。这是 throw new Error(...) 优于 throw "出错了" 的直接原因——字符串没有栈,出了事你只知道结果不知道来路。
  • cause:底层原因。构造函数第二个参数写 { cause: 原始错误 },就能把「更底层那个真正的错误」挂在上面。它解决一个常见矛盾:外层想换上一句对调用方友好的说明,又不想丢掉底层的技术细节——cause 让两者同时保留。这个字段在 Pi 源码里用得非常多。

自定义错误:让调用方能分辨失败的种类 ​

catch 接住错误之后,第一件事往往是判断「是哪一种失败」——因为不同的失败要用不同的办法处理:网络超时可以重试,参数写错了重试一万次也没用。

那怎么分辨?最容易想到、也最不该用的办法是比对 message 文本:if (e.message.includes("超时"))。这是把「给人看的说明」当成了「给代码看的接口」——哪天有人把提示语从「超时」改成「请求超时」,所有依赖它的判断就全部静默失效,而且没有任何编译错误提醒你。

正确做法是定义自己的错误 class,并挂一个稳定的错误码字段:

ts
type ToolCallErrorCode = "invalid_json" | "missing_name";

class ToolCallError extends Error {
  public code: ToolCallErrorCode;

  constructor(code: ToolCallErrorCode, message: string, cause?: Error) {
    super(message, cause === undefined ? undefined : { cause });
    this.name = "ToolCallError";
    this.code = code;
  }
}

try {
  throw new ToolCallError("missing_name", "缺少 name 字段");
} catch (e) {
  if (e instanceof ToolCallError) {
    console.log(`收到 ${e.name},错误码 ${e.code}:${e.message}`);
  } else if (e instanceof Error) {
    console.log(`其它 Error:${e.message}`);
  } else {
    console.log(`非 Error 的抛出值:${String(e)}`);
  }
}

真实输出:

收到 ToolCallError,错误码 missing_name:缺少 name 字段

三个要点。extends Error(2.4 的 class 继承)让它仍然是一个 Error,instanceof Error 照样成立,stack 照样有——你只是在标准错误上加东西,没有另起炉灶。super(...) 把 message 和 cause 交给基类处理。code 是字面量 union(2.3 讲的可辨识联合思路):取值就那么几个,写错一个字母是编译错误,而且 switch (e.code) 时编译器还能帮你检查有没有漏掉分支。message 随时可以润色,code 是承诺不变的接口——这个分工是错误设计的核心。

catch 里的 unknown:为什么编译器不让你直接用 ​

试试在 catch 里直接读属性:

ts
try {
  JSON.parse("{ 坏掉的 JSON");
} catch (e) {
  console.log(e.message);
}

tsc --noEmit --strict 的真实报错:

d.ts(4,15): error TS18046: 'e' is of type 'unknown'.

原因回到本章开头那句话:JavaScript 允许 throw 任何值。一个第三方库完全可能 throw "boom" 或者 throw { code: 42 }。编译器无法证明抛出来的一定是 Error,所以从 TypeScript 4.4 起,strict 模式下 catch 变量的类型就是 unknown(对应的开关叫 useUnknownInCatchVariables,strict: true 会自动打开它)。2.4 讲过 unknown 的含义是「不知道,所以在收窄之前什么都不许做」。

于是 catch 里就有了一套固定的三步流程,本章前面那段代码已经完整演示过:

  1. 先试最具体的自定义错误:if (e instanceof ToolCallError)——命中就能安全读 e.code;
  2. 再试通用的 Error:else if (e instanceof Error)——至少能读 e.message;
  3. 最后兜底:String(e)——什么都不假设,也绝不崩溃。

顺序不能反:ToolCallError 也是 Error,先写 instanceof Error 会把它一起吞掉,永远进不了第一个分支。

⚠️ 常见误解用 catch (e: any) 绕过编译错误
把 catch (e) 改成 catch (e: any) 确实能让编译器闭嘴,但这只是把「编译期报错」换成了「运行期崩溃」——真的有人 throw 了一个字符串时,e.message 得到 undefined,日志里留下一句「错误:undefined」,线索全无。多写两个 instanceof 分支,换来的是任何输入都不会二次崩溃。顺带一提,TypeScript 只允许把 catch 变量标注成 any 或 unknown,写 catch (e: Error) 是语法错误——因为这个承诺根本没人能保证。

异步的错误:await 处抛出,忘了 await 就没人接 ​

2.5 讲过:await 一个失败的 Promise,失败会在 await 那一行以异常形式抛出。这意味着异步代码不需要另一套错误机制,普通的 try/catch 就能接住:

ts
try {
  const reply = await fetchModelReply(); // 失败在这一行抛出
  console.log(reply);
} catch (e) {
  console.log(`请求失败:${e instanceof Error ? e.message : String(e)}`);
}

危险的是漏掉 await。少了它,失败就发生在 try 块之外——try 块本身早已顺利执行完毕:

ts
async function fetchModelReply(): Promise<string> {
  throw new Error("模型服务返回 500");
}

try {
  fetchModelReply(); // 忘了 await
  console.log("try 块正常走完了——错误一点没被接住");
} catch {
  console.log("这一行永远不会执行");
}

真实输出(Node.js v26.5.0,进程退出码 1):

try 块正常走完了——错误一点没被接住

Error: 模型服务返回 500
    at fetchModelReply (…(省略:本机文件路径与堆栈其余各行))

这就是未处理的 Promise 失败(Unhandled Rejection)。fetchModelReply() 立刻返回了一个 Promise,try 块随即正常结束、catch 从未被触发;直到事件循环稍后发现这个 Promise 失败了却无人认领,Node.js 才报告错误并以非零退出码结束进程。规矩很朴素:每条异步链的尽头,要么有人 await 并 try/catch,要么挂着 .catch。

图 2.9-1 一次失败沿调用栈向上冒泡的旅程
阅读顺序:从上到下。前三步是正常的调用方向,SyntaxError 产生之后走的是反方向。重点看中间两段:parseToolCall 把底层的 SyntaxError 翻译成带错误码的 ToolCallError,细节存进 cause 所以线索不丢;而 handleRequest 一行错误处理代码都没写,失败自动穿过它——这就是「第二条返回通道」省下的样板代码。最下面的 finally 说明收拾现场这一步不受成败影响。本章实验的 src/parse-tool-call.ts 就是图中间那一层的完整实现。

为什么要自动化测试 ​

现在换到本章的后半段。上面写的错误处理,你怎么知道它真的对?

手工验证的办法是运行程序、看看输出对不对。它有三个躲不开的问题。第一,错误分支很难手工触发:一个函数有四种失败方式,你得手工构造四种坏输入,跑四遍,看四次输出——而且下次改代码时要再来一遍。第二,改一处要验证全部:今天为了修一个 bug 改了 parseToolCall,谁能保证另外三条分支没被弄坏?靠记忆和眼力,迟早会漏。这类「本来好好的功能被新改动弄坏」的问题有个专门的名字:回归(Regression)。第三,知识留不下来:「这个函数遇到缺省的 arguments 会补成空对象」这种约定只存在于作者脑子里,三个月后连他自己也不确定了。

自动化测试就是把这些验证写成代码,一条命令跑完全部,几十毫秒给出「全绿」或者「第 3 条红了」。它顺带解决了第三个问题:一份写得好的测试,本身就是这个函数最准确的说明书——因为它必须能真的跑通,不会像注释那样悄悄过期。这就是常说的测试即文档。

📘 概念断言(Assertion)
测试代码里表达「我断定这里应该是什么样」的语句,例如 assert.equal(half(8), 4)。断言成立就静悄悄地过去,不成立就抛出一个 AssertionError,测试框架捕获它并把这条测试标红。所以断言本质上就是本章前半段的 throw——测试框架不过是一个专门收集异常并汇总报告的运行器。

node:test + node:assert:最小用法 ​

Node.js 从 18 版起内置了测试运行器 node:test(20 版后转为稳定),断言库 node:assert 更是一直都在。用它们写测试不需要安装任何第三方包。先有一个被测函数 half.ts:

ts
export function half(n: number): number {
  if (n % 2 !== 0) {
    throw new Error(`${n} 不是偶数`);
  }
  return n / 2;
}

再写测试文件 half.test.ts(文件名带 .test 是通行约定,方便工具批量找到它们):

ts
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { half } from "./half";

describe("half", () => {
  it("偶数返回它的一半", () => {
    assert.equal(half(8), 4);
  });

  it("奇数抛出 Error", () => {
    assert.throws(() => half(7), /不是偶数/);
  });
});

用 tsx --test half.test.ts 运行,真实输出(毫秒数每次不同):

▶ half
  ✔ 偶数返回它的一半 (0.265792ms)
  ✔ 奇数抛出 Error (0.156709ms)
✔ half (0.82875ms)
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 95.261

要认识的东西不多:

  • describe("名字", () => { ... }) 把一组相关测试归到一个标题下,只是为了输出好读,可以嵌套。
  • it("这个函数应该……", () => { ... }) 是一条测试用例。名字要写成一句人话的行为描述——上面两行 it 连起来读就是 half 的完整说明书,这正是「测试即文档」落地的地方。
  • assert.equal(实际值, 期望值) 比较两个值(node:assert/strict 这个入口用的是严格比较,不做类型转换)。比较对象内容用 assert.deepEqual,它逐层比较字段,而不是判断「是不是同一个对象」。
  • assert.throws(函数, 校验) 断言这个函数必须抛出异常。第二个参数用来校验抛出的到底是什么:可以是上面这样的正则(匹配 message),也可以是一个返回布尔值的函数——本章实验里用后者精确锁定「是 ToolCallError 且 code 等于 invalid_json」。只写 assert.throws(fn) 而不校验是很弱的断言:抛出任何异常都算通过,哪怕抛的是一个完全不相干的 bug。

把第一条断言故意改成 assert.equal(half(8), 5),真实的失败报告是这样(已省略堆栈):

  ✖ 偶数返回它的一半 (1.282917ms)
ℹ pass 1
ℹ fail 1

✖ failing tests:
✖ 偶数返回它的一半 (1.282917ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  4 !== 5

报告直接给出「哪条测试、哪个断言、实际值与期望值分别是什么」。这就是自动化测试的日常价值:把「程序好像哪里不对」变成「第 7 行的这个断言,得到 4 却期望 5」。

图 2.9-2 有测试之后的日常改代码循环
阅读顺序:从左到右,注意它是一个环。关键在于「运行一条命令」这一步的成本极低——几十毫秒、无需手工构造输入,所以你才愿意每改一次就跑一次,把回归挡在提交之前。没有自动化测试时,这个环的成本高到人们只会在「感觉有必要」时才走一遍,于是 bug 就漏了出去。本章实验第 4 步会让你亲手走一遍红→改→绿。

Pi 中哪里用到了它 ​

本章教的三样东西——带错误码的自定义错误、把 unknown 收窄并翻译成自己的错误类型、测试文件——在 Pi 里全是主力写法,而且形态和上面的示例几乎一模一样。

先看错误定义。Pi 在 packages/agent/src/harness/types.ts 里集中定义了一整族错误 class:FileError、ExecutionError、CompactionError、BranchSummaryError——每一个都配一个字面量 union 的错误码类型。文件操作用的这个是典型代表:

ts
/** Error returned by {@link FileSystem} file operations. */
export class FileError extends Error {
	/** Backend-independent error code. */
	public code: FileErrorCode;
	/** Absolute addressed path associated with the failure, when available. */
	public path?: string;

	constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) {
		super(message, cause === undefined ? undefined : { cause });
		this.name = "FileError";
		this.code = code;
		this.path = path;
	}
}
earendil-works/pi@16787ad第 172–185 行在 GitHub 查看 ↗
Pi 的文件错误:extends Error、错误码 code、可选的 cause,构造函数三行——与本章示例的 ToolCallError 逐行对应。紧邻上方第 162–170 行的 FileErrorCode 是一个八个成员的字面量 union(aborted / not_found / permission_denied / …)。源码注释里的 "Backend-independent"(与后端无关)点出了错误码存在的理由:无论底层换成哪种文件系统实现,调用方看到的错误码都是同一套。

再看 unknown 的收窄。Node.js 的文件 API 抛出的错误五花八门,Pi 用一个专门的函数把它们统一翻译成 FileError:

ts
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
	return error instanceof Error && "code" in error;
}

function toFileError(error: unknown, fallbackPath?: string): FileError {
	if (error instanceof FileError) return error;
	const cause = toError(error);
	const nodeError = isNodeError(error) ? error : undefined;
	const path = typeof nodeError?.path === "string" ? nodeError.path : fallbackPath;
	if (nodeError) {
		const message = nodeError.message;
		switch (nodeError.code) {
			case "ABORT_ERR":
				return new FileError("aborted", message, path, cause);
			case "ENOENT":
				return new FileError("not_found", message, path, cause);
			// …(省略:EACCES / EPERM / ENOTDIR / EISDIR / EINVAL 五个分支,形态完全相同)
		}
	}
	return new FileError("unknown", cause.message, path, cause);
}
earendil-works/pi@16787ad第 101–129 行在 GitHub 查看 ↗
把任意 unknown 翻译成带稳定错误码的 FileError。三段结构与本章讲的收窄流程完全一致:先试最具体的(已经是 FileError 就原样返回),再用 type guard isNodeError 收窄到「带 code 的 Node.js 错误」并逐个映射,最后兜底成 unknown。收窄的结果被存进了变量 nodeError:三元表达式的真分支里 error 已被收窄,所以 nodeError 的类型是「ErrnoException 或 undefined」,下面的 if (nodeError) 再收窄一次。报错的路径优先取 Node.js 错误对象自带的 path,没有才用调用方传进来的 fallbackPath。每个分支都把原始错误放进 cause,底层细节一点没丢。isNodeError 正是 2.4 讲的自定义 type guard(返回类型写作「参数名 is 类型」),而它调用的 toError 就是 2.4 引过的那个把 unknown 规整成 Error 的函数。

调用它的地方就是最普通的异步 try/catch。readTextFile(同文件第 716–726 行)先检查 AbortSignal(2.7 的内容),然后 try { return ok(await readFile(...)) } catch (error) { return err(toFileError(error, resolved)) }——await 处抛出的失败被接住,翻译成 FileError,再装进 2.4 见过的 Result 里返回。注意这个组合:Pi 在内部用 throw 传播失败,但在公开的接口边界上换成 Result 返回值,逼迫调用方必须检查。两条通道各用在合适的地方,这个取舍在 5.6 取消与错误处理 会展开。

最后是测试。Pi 用的测试框架不是 node:test 而是 vitest(一个流行的第三方测试框架),但常用 API 几乎同源——describe / it 的用法完全一样,只是断言从 assert.equal(a, b) 换成了链式的 expect(a).toBe(b):

ts
import { mkdtempSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { expandPath, resolveReadPath, resolveToCwd } from "../src/core/tools/path-utils.ts";

describe("path-utils", () => {
	describe("expandPath", () => {
		it("should expand ~ to home directory", () => {
			const result = expandPath("~");
			expect(result).not.toContain("~");
		});
earendil-works/pi@16787ad第 1–12 行在 GitHub 查看 ↗
Pi 的一个真实测试文件开头。结构与本章示例逐项对应:先 import 被测函数,再 describe 分组(这里嵌套了两层),每个 it 用一句英文行为描述当作用例名。beforeEach / afterEach 是本章没讲的钩子,用来在每条用例前后建立和清理临时目录。这类测试文件在 Pi 仓库里有数百个,仅 packages/coding-agent/test/ 一个目录下就有上百个。

从源码结构看,Pi 的测试主要覆盖两类东西:纯函数的边界情况(像上面的路径处理),以及带编号的历史 bug——packages/coding-agent/test/suite/regressions/ 目录下的文件名都以 issue 编号开头,例如 5724-sigterm-signal-exit.test.ts。后者是「测试即文档」最直白的形式:每个文件都在说「这个 bug 修过,别再犯」。

实践任务 ​

🛠 实践任务带错误分支的解析函数 + 6 个自动化测试labs/typescript-basics/12-errors-tests

目标:把本章两半内容各走一遍——用自定义错误 class 处理四种失败,再用 node:test 给它们写测试并跑绿。实验目录:labs/typescript-basics/12-errors-tests(全书实验索引见实践任务索引)。

步骤:

  1. 进入实验目录,安装依赖并运行两条命令:

    sh
    cd labs/typescript-basics/12-errors-tests
    npm install
    npm start   # 错误处理的四个演示
    npm test    # 6 个自动化测试
  2. 对照 expected-output.txt 核对 npm start 的四个部分,重点看第 3 部分——两次「关闭日志」分别来自一次成功和一次失败;

  3. 读 src/parse-tool-call.ts,找出四个错误码分别对应哪一行 throw,以及 cause 是在哪一行装进去的;

  4. 改一改:把 src/parse-tool-call.ts 里 missing_name 那个 if 整段删掉,npm test 观察哪一条测试变红、失败报告怎么写,再改回来;

  5. 加练:把 src/main.ts 第 4 部分 await fakeReadFile("ghost.txt") 的 await 删掉,运行 npm start,观察「未处理的 Promise 失败」如何绕过 try/catch 让进程非零退出。

预期现象:npm start 与 expected-output.txt 逐字一致;npm test 显示 pass 6、fail 0;第 4 步应看到「缺少 name 字段时抛出 missing_name」那条变成 ✖,统计行变成 fail 1。

如何判断成功:四步改动的结果都与你的预测一致,并能口头回答——为什么分辨失败要靠 code 字段而不是比对 message 文本?

常见错误:tsx: command not found 说明还没在实验目录里 npm install;在 catch (e) 里直接写 e.code 会编译报错 'e' is of type 'unknown',必须先用 instanceof 收窄。

对应源码位置:packages/agent/src/harness/types.ts 第 160–173 行(FileError,实验里的 ToolCallError 是它的简化版)、packages/agent/src/harness/env/nodejs.ts 第 92–118 行(toFileError 的收窄流程)。

本章小结 ​

  • 失败需要第二条返回通道:哨兵值会被无声忽略,Result 要求每层转发,而 throw 让失败沿调用栈自动冒泡,中间不关心的层一行代码都不用写;无人接住则程序终止,失败绝不会消失。
  • try 圈出可能失败的代码(抛出后块内剩余代码被跳过);catch 决定怎么办;finally 无论成败都执行,专门用来收拾现场。空 catch {} 会让失败彻底消失,要慎用。
  • Error 的四个字段:message(给人看)、name(种类名)、stack(调用栈,只有 Error 对象才有)、cause(底层原因,翻译错误时用它保住线索)。
  • 自定义错误 = extends Error + 一个字面量 union 的 code 字段。message 给人看、随时可改;code 给代码看、承诺稳定——分辨失败种类必须靠 code。
  • strict 模式下 catch (e) 的 e 是 unknown(TypeScript 4.4+ 的 useUnknownInCatchVariables),因为任何值都能被 throw。收窄顺序固定:最具体的自定义错误 → Error → 兜底 String(e),顺序反了会被父类吞掉。
  • 异步失败在 await 那一行以异常形式抛出,普通 try/catch 就能接住;忘写 await,失败就发生在 try 块之外,变成未处理的 Promise 失败,Node.js 报错并以非零退出码结束进程。
  • 自动化测试解决三件事:难手工触发的错误分支、改一处要验证全部(防回归)、口头约定会遗忘(测试即文档)。node:test + node:assert 是 Node.js 内置的零依赖最小方案:describe 分组、it 一条用例、assert.equal / assert.deepEqual 比较值、assert.throws 断言抛出——assert.throws 一定要带第二个参数校验抛的是什么。
  • Pi 把这套写法用到了底:types.ts 里一族带错误码的自定义错误、toFileError 把 unknown 逐层收窄成 FileError、数百个 vitest 测试文件(含以 issue 编号命名的回归测试)。

关键术语:异常(Exception)、throw、try / catch / finally、调用栈(Call Stack)、冒泡、哨兵值、Error、name / message / stack / cause、自定义错误、错误码(error code)、useUnknownInCatchVariables、未处理的 Promise 失败(Unhandled Rejection)、自动化测试、断言(Assertion)、测试用例、回归(Regression)、测试即文档、node:test / node:assert、vitest、describe / it / assert.equal / assert.deepEqual / assert.throws

关键源码索引:packages/agent/src/harness/types.ts 的 FileErrorCode 与 FileError(第 150–173 行),同文件还有 ExecutionError、CompactionError、BranchSummaryError、SessionError、AgentHarnessError(第 175–266 行);packages/agent/src/harness/env/nodejs.ts 的 isNodeError 与 toFileError(第 92–118 行)、readTextFile 的异步 try/catch(第 499–508 行);packages/coding-agent/test/path-utils.test.ts(vitest 测试文件样例,第 1–12 行)

自测问题:

  1. 有人在 catch 里写 if (e.message.includes("not found")) 来判断「文件不存在」。这样写有哪两个问题?换成本章的写法要怎么改?
  2. catch (e) 里 e 的类型为什么是 unknown 而不是 Error?如果把收窄顺序写反——先 if (e instanceof Error) 再 else if (e instanceof MyError)——会发生什么?
  3. 一个 async 函数里写了 try { doAsyncWork(); } catch { ... }(漏了 await),而 doAsyncWork 会失败。程序会打印什么、退出码是几?为什么 catch 接不住?
  4. assert.throws(() => parse("坏输入")) 这条断言在什么情况下会「通过但毫无意义」?加上第二个参数能挡住哪类问题?

下一章预告:第二部分到此结束——TypeScript 的类型、异步、模块、错误与测试全部到齐,读 Pi 源码的语言门槛已经清空。接下来进入第三部分「Agent 基础概念」,从 3.1 LLM 与模型 API 开始:大语言模型(LLM,Large Language Model)到底是什么、一次 API 调用发出去和收回来的分别是什么、为什么它天生不记得上一句话——这些是理解 Agent Harness 存在意义的起点。

✅ 自测问题参考答案先自己回答,再点开对照
  1. 两个问题:① e 的类型是 unknown,直接访问 .message 编译就不通过,必须先收窄;② 就算收窄了,message 是给人看的文案,会随 Node.js 版本、系统语言变化,拿它做判断等于把正确性押在一句随时可能改的提示语上。本章的写法是认 code:先用类型守卫(error instanceof Error && "code" in error)收窄,再判断 error.code === "ENOENT",不认识的错误原样抛回去。Pi 的 toFileError 进一步把这些 Node 错误码翻译成自己那套与后端无关的稳定错误码。
  2. 因为 JavaScript 允许 throw 任何值——字符串、数字、普通对象都行,所以编译器唯一诚实的选择就是 unknown(strict 模式下的 useUnknownInCatchVariables)。收窄顺序写反会被父类吞掉:MyError extends Error,所以 e instanceof Error 对自定义错误也成立,先写它就会让所有自定义错误都掉进第一个分支,后面那个 else if (e instanceof MyError) 永远进不去,code 之类的专属字段也就用不上了。顺序必须从最具体到最一般。
  3. 会先打印 try 块里剩下的内容(比如「try 块正常走完了」),然后 Node.js 报告一条未处理的 Promise 失败并以非零退出码(1)结束进程。catch 接不住是因为漏了 await:doAsyncWork() 立刻返回一个 pending 的 Promise,try 块随即正常执行完毕、catch 从未被触发;失败是事件循环稍后才发现的,那时早已离开 try 块。
  4. 当被测函数抛出的是别的异常时——比如函数里有个拼写错误导致 TypeError,或者抛的是完全不相干的 bug——这条断言照样「通过」,因为它只要求「抛了点什么」。加上第二个参数(正则匹配 message,或一个返回布尔值的校验函数)就能挡住这类问题:它要求抛出的必须是你预期的那一种错误,比如「是 ToolCallError 且 code 等于 invalid_json」。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)