一个 Extension 在 Demo 中工作,不等于能进入生产。它可能:
- Factory 启动失败;
- Handler 卡住整个事件链;
- Reload 后留下两个 Watcher;
- 与另一个 Extension 抢同名 Tool;
- 在 Agent End 中递归启动新 Run;
- 把 Session A 的状态泄漏到 Session B;
- 更新 Package 后不再兼容当前 Pi。
本章给 Extension 建立生产级设计与测试方法。
1. 可靠性的六个维度
flowchart TD
R["Reliable Extension"] --> I["Initialization"]
R --> E["Event/Tool errors"]
R --> L["Lifecycle cleanup"]
R --> C["Conflict/concurrency"]
R --> P["Performance/security"]
R --> V["Test/version/distribution"]
不要只测试 Happy Path。
2. Factory 初始化失败
Module Import、默认导出检查或 Factory 执行失败,Loader 返回:
{
extension: null,
error: "Failed to load extension: ...",
}
该 Extension 对象不会进入最终 Extension 列表,后续 Path 继续加载。但 Factory 失败并不等于
“事务回滚”:Factory 在抛错前对共享 Runtime 做过的预绑定注册,仍可能残留。例如排队等待
Runner 绑定的 registerProvider() 可能随后被 Flush,registerFlag() 写入的默认值也可能保留。
因此可靠的 Factory 应:
- 先完成读取、远程请求、Schema 校验等可能失败的工作;
- 把注册动作集中到最后一个尽量不失败的阶段;
- 不把“Factory Throw”当成撤销此前副作用的机制。
Timer、Watcher、Socket 与 EventBus Listener 等长期资源不要在 Factory 中启动,应延迟到
session_start 或首次使用。Factory 也可能被用于最终不启动 Session 的加载流程;更危险的是,
若它启动资源后抛错,Extension 对象及其 Shutdown Handler 都会被丢弃,之后无法依靠
session_shutdown 清理。
桌面 Agent 应:
- 展示 Extension Path;
- 展示 Loader 返回的 Path 与 Error;
- 标记它提供的 Tool/Policy 未启用;
- 对安全关键 Extension 选择 Fail Startup,而不是静默降级。
Pi Loader 默认是收集错误并继续;“关键 Policy 缺失是否阻止应用启动”由宿主决定。 默认 Loader 诊断是格式化后的错误文本,并不保证带 Stack;需要 Stack 时,宿主应在加载边界 增加自己的诊断与日志采集。
3. Async Factory 的 Timeout
Loader 会 await factory(api),没有自动 Timeout。远程初始化必须自行限时:
export default async function (pi: ExtensionAPI) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);
try {
const config = await fetchConfig(controller.signal);
registerFromConfig(pi, config);
} finally {
clearTimeout(timer);
}
}
还应决定失败策略:
- Required Config:Throw,Extension 不加载;
- Optional Enhancement:注册降级能力并报告诊断。
4. Handler 执行失败
错误语义不统一:
| Handler | 抛错后 |
|---|---|
| 普通 Lifecycle/Context/Result/Input | Error Listener 收到,后续继续 |
project_trust |
收集 Error,继续找 Decision |
tool_call |
错误传播,Tool 失败且不执行 |
| EventBus Listener | Console Error;Emit 不 Await |
安全 Handler 要“故障即拒绝”,tool_call 已是 Fail Closed。普通 Audit Handler 抛错不会
阻断 Tool,因此关键 Audit 不能只依赖事后观察 Event。
5. Handler Timeout 与取消
Runner 顺序 await Handler,没有自动 Timeout。
pi.on("tool_result", async (event, ctx) => {
const timeout = new AbortController();
const timer = setTimeout(() => timeout.abort("redaction timeout"), 3_000);
const signal = ctx.signal
? AbortSignal.any([ctx.signal, timeout.signal])
: timeout.signal;
try {
return await redactRemote(event, signal);
} catch {
// tool_result 的普通 Throw 会被 Runner 隔离,原结果仍可能继续流向模型。
// 安全脱敏不能靠 Throw 实现 Fail Closed,而要显式返回安全替代内容。
return {
content: [{ type: "text", text: "[结果因脱敏服务不可用而隐藏]" }],
details: null,
isError: true,
};
} finally {
clearTimeout(timer);
}
});
要求:
- 把
ctx.signal与独立 Timeout Signal 一起传给 Fetch/Model/Process; - Timeout 后真正 Abort 自己的工作,而不只是停止等待;
- 明确 Timeout 时 Fail Open/Closed;
- 不在
message_update高频 Event 做远程调用。
对于 Approval,Timeout 应默认拒绝,而不是默认允许。
普通 tool_result Handler Throw 并不是 Fail Closed:Runner 会报告错误后继续使用已有结果。
安全脱敏要返回安全替代结果,或在更早的 tool_call/宿主 Policy 边界拒绝执行。
6. Tool 的 Abort
Custom Tool 必须把 signal 传到底层:
Extension Tool
→ Driver/Fetch/Child Process
→ Abort-aware cancellation
Abort 只能请求停止,不能回滚已经发生的写入。Tool 应:
- 在 Side Effect 前检查 Signal;
- 使用 Idempotency Key;
- 对多步写入使用 Transaction;
- 返回前完成 Cleanup;
- 不在后台继续 Detached Work。
7. Extension 冲突
跨 Extension:
- Tool/Flag:前者胜,产生 Conflict Diagnostic;
- Command:都保留,获得
name:1/name:2; - Handler:全部运行;
- EventBus Channel:没有自动 Namespace。
最终 Registry 中 Extension Tool 还能覆盖 Built-in,SDK Custom Tool 又可覆盖 Extension。
启动门禁应把意外冲突视作错误,而不是依赖加载顺序碰运气。
8. 重复注册
同一个 Extension 内:
- Tool/Command/Flag Map:后写覆盖;
- Handler Array:重复订阅会执行多次;
- Flag Definition 可后写,但 Default Value 可能保留第一次。
Factory 应只注册一次。不要在某个事件 Handler 中动态再次调用 pi.on();同一 Extension
实例里显式重复订阅,才会让 Handler 重复执行。New/Resume/Fork/Reload 会重建
Extension 对象与 Runner,session_start 本身不会让旧 Runner 的 Handler 自动累积。
但 Module-level 资源与 EventBus 可以跨实例存活。长期 EventBus Subscription 要保存 Unsubscribe,并在 Shutdown 清理,否则重载后的新实例可能与旧 Listener 同时存在。
9. 事件递归
flowchart LR
A["agent_settled"] --> S["sendUserMessage"]
S --> R["new Agent Run"]
R --> A
防护方法:
- CustomType/Source Filter;
- Session Entry Mark;
- Run-local Boolean Guard;
- Max Iteration;
- Business Idempotency Key;
- 在
ctx.hasPendingMessages()时不追加。
Guard 的生命周期要匹配需求。全局 Module Variable 可能误伤其他 Session。
10. Reload 清理
Reload:
session_shutdown(reason=reload)
→ reload resource/factory
→ rebuild runner/tools
→ session_start(reason=reload)
在 Shutdown 关闭:
- Timer;
- Watcher;
- Socket;
- Child Process;
- EventBus Subscription;
- Temporary Directory;
- External Lease。
清理必须幂等,因为 Quit、Reload、New、Resume、Fork 都可能进入 Shutdown。
11. Session 切换
New/Resume/Fork 会 Tear Down 旧 Runtime 并建立新 Runtime。状态恢复放在 session_start:
pi.on("session_start", (_event, ctx) => {
state = rebuildFrom(ctx.sessionManager.getBranch());
});
旧 Command Context 不应在 Session Replacement 后继续使用;后续动作放进 withSession(nextCtx)。
需要跨 Session 的 Account Config 用外部 Store,不要从“上一个 Session 的最后状态”隐式继承。
还要注意 v0.82.0 的实现边界:AgentSession.reload() 会重建 Runner,却没有像完整 Session
Replacement 那样统一使旧 Runner/Context 失效。Extension 仍应把旧 pi/ctx 视为过期;
桌面宿主可增加 Reload Generation、主动 Cleanup 和“旧 Context 不得产生副作用”的回归测试。
12. 多会话并发
桌面应用可能同时维护多个 AgentSession。每个 Session 应拥有:
- 独立 Extension Runtime;
- 独立 Closure State;
- 独立 Abort/Queue;
- 独立 Approval Request;
- 独立 Session Store。
风险来自 Module-level Singleton:
const mutableState = new Map(); // 所有 Factory Instance 共享
如果必须共享 Connection Pool,Key 至少包含 Tenant/Workspace,并确保并发、权限与 Shutdown 引用计数正确。
13. 性能预算
每个 Hook 都在关键路径:
| Event | 性能风险 |
|---|---|
| input/before_agent_start | 增加首 Token 延迟 |
| context | 每个 LLM Turn 都执行 |
| message_update | 每个 Stream Update 都执行 |
| tool_call | 阻塞 Tool 启动 |
| tool_result | 阻塞模型看到结果 |
| agent_settled | 可影响“完成”后操作 |
为 Handler 记录:
- Count;
- P50/P95/P99;
- Timeout/Error;
- Payload Size;
- External Dependency。
不要在每个 Token Update 序列化整个 Session。
14. 安全边界
Extension 是本地代码,拥有完整进程权限。可靠性与安全共同要求:
- 来源 Pin;
- Code Review;
- Dependency Review;
- Least Tool/Network/File Permission;
- Final Host Policy;
- Secret Redaction;
- Audit Integrity;
- Safe Update/Rollback。
ExtensionAPI 的类型边界和 Project Trust 都不是 OS Sandbox。
15. 测试金字塔
flowchart TD
U["Unit<br/>pure policy/schema"] --> H["Harness<br/>events/tools"]
H --> S["Session lifecycle<br/>reload/switch"]
S --> E["End-to-end<br/>desktop bridge"]
E --> V["Version compatibility"]
Unit
测试 Pattern、SQL Classifier、Argument Canonicalization。
Harness
用 Inline Factory + Faux Provider,断言 Event Order、Block、Result Patch、Error Listener。
Lifecycle
测试 Reload、Shutdown Cleanup、State Reconstruction、Old Context。
Desktop
测试 Approval Dialog、Abort、Crash Recovery、Multi-window Routing。
16. Faux Provider 测试 Extension
const extension: InlineExtension = (pi) => {
pi.on("tool_call", (event) => {
if (event.toolName === "danger") {
return { block: true, reason: "denied" };
}
});
};
const harness = await createHarness({
extensionFactories: [extension],
tools: [dangerTool],
});
让 Faux 返回 danger Tool Call,断言:
- Tool Execute Count 为 0;
- Execution End
isError=true; - ToolResult Message 含 Reason;
- Extension Error 为空。
这是 Pi 仓库内部 Test Harness 的形状;应用可以建立等价公开测试夹具。
17. 测试 Handler Error
至少覆盖:
- 普通 Handler Throw 后,后续 Handler 仍执行;
- tool_call Handler Throw 后,Tool 不执行;
- tool_result Handler Throw 后,原/前一 Patch 保留且链继续;
- Error Listener 收到 Path、Event、Message;
- Error Listener 自身不能破坏 Agent 主流程。
最后一点要由宿主 Binding 测试,因为 Runner emitError() 会直接调用 Listener。
18. 版本兼容
Extension Package 应声明:
- 自身 SemVer;
- 支持的 Pi Version Range;
- Node/Bun Runtime 要求;
- Migration/Breaking Change;
- Provider/Tool Result Schema Version;
- Session Custom Entry Schema Version。
不要只靠 TypeScript Compile:Event Order、Reload 行为和 Provider Payload 都可能在版本间改变。
这里的“支持 Pi Version Range”是扩展文档与 CI Compatibility Matrix,不是当前 Loader 会读取
并校验的 Manifest 字段。官方 Package 示例里 Pi Core Package 通常放在
peerDependencies,其范围为 "*";若要严格限制版本,应由发布流程、安装器或宿主门禁执行。
为支持矩阵运行 Contract Tests。
19. Pi Package 分发
{
"name": "@team/pi-policy",
"version": "2.1.0",
"keywords": ["pi-package"],
"pi": {
"extensions": ["./extensions/policy.ts"]
}
}
Runtime Dependency 放 dependencies;Pi Core Package 按官方规则放 peerDependencies。npm/Git
来源固定版本或 Ref。
Package 有完整系统访问,安装前审查 Extension、Dependency、Install Behavior 与附带 Skill。
20. 发布门禁
Source Pin
→ Static Review
→ Unit/Harness/Lifecycle Tests
→ Multi-session/Abort Tests
→ Permission Diff
→ Package Smoke Test
→ Signed Artifact/Hash
→ Canary
→ Rollback Ready
Pi 提供 Package Mechanism,但签名、Canary、Permission Diff 和 Rollback UI 是桌面产品治理。
21. 可观测性
记录:
extension source/version
event type
handler duration
toolCallId/sessionId
decision/result class
error/timeout/abort
reload generation
不要记录:
- Raw Secret;
- 完整 Prompt(默认);
- 未脱敏 Tool Content;
- OAuth Credential;
- 用户拒绝原因中的敏感文本。
22. 故障策略表
| 场景 | 推荐默认 |
|---|---|
| 可选装饰 Extension 加载失败 | 降级 + 可见诊断 |
| 安全 Policy Extension 加载失败 | 阻止 Agent 启动/高风险 Tool |
| Approval Timeout | 拒绝 |
| Audit Sink 暂时失败 | 本地缓冲或阻止高风险动作 |
| Dynamic Model Catalog 失败 | 使用最后可信 Catalog/显示不可用 |
| Resource Discovery 失败 | 保留其他 Extension,显示具体 Path |
| Cleanup 失败 | 记录并继续其余 Cleanup,必要时终止 Worker |
这些是宿主策略建议;Pi 默认 Loader/Runner 的行为在前文已单独说明。
23. 常见误区
“Loader 会因为一个 Extension 失败而停止”
默认不会。安全关键场景由宿主提升为 Startup Failure。
“所有 Handler 都有自动 Timeout”
错误。Extension 要自己实现。
“Reload 会自动清理我的 Watcher”
错误。只会发 Lifecycle Event;资源由 Extension 关闭。
“Module-level State 天然按 Session 隔离”
错误。Module Singleton 可能被多个 Instance 共享。
“TypeScript 通过就代表版本兼容”
错误。还要 Contract 与 Lifecycle Test。
24. 本章小结
- Loader 默认收集 Extension 初始化错误并继续,关键 Policy 的 Fail Startup 由宿主决定;
- Async Factory/Handler 没有自动 Timeout;
- tool_call Error Fail Closed,普通 Handler 多数隔离继续;
- Abort 不回滚副作用,Tool 要支持 Transaction/Idempotency;
- 冲突、重复注册和 EventBus Channel 都要显式治理;
- Shutdown 必须幂等清理所有长期资源;
- Session State、Global State 与 Module Singleton 不可混用;
- 高频 Hook 需要性能预算;
- Faux Harness 应覆盖 Block、Patch、Error、Abort 和 Lifecycle;
- Version Compatibility 需要 Contract Test,不只 Type Check;
- Package 分发必须 Pin 来源、审查依赖并准备回退。
25. 自测
- Factory 失败时 Pi 默认是否停止加载全部 Extension?
- 哪类 Handler Error 会让 Tool Fail Closed?
- Approval Handler 为什么必须有 Timeout?
- Abort 为什么不能替代 Transaction?
- 为什么不要在事件 Handler 中动态重复 pi.on?
- 多 Session 怎样避免 Module Singleton 泄漏?
- message_update Handler 有什么性能风险?
- Extension Harness 至少要测哪些失败路径?
- TypeScript Compile 为什么不足以证明版本兼容?
- 安全 Policy Extension 加载失败时宿主应怎样处理?