一个 Agent 能回答问题,只说明主路径可用。一个桌面 Agent 能进入生产,还必须证明:
- 不该执行的动作确实不会执行;
- 崩溃、取消和重试不会制造第二次副作用;
- Session 能恢复;
- Skills 与 Extensions 更新后仍兼容;
- 模型行为变化能被评估;
- 出错时能定位到 Provider、Tool、Extension、Session 或 Bridge。
本章把示例项目变成可发布、可升级、可回退的产品。
1. 学习目标
完成本章后,你应能够:
- 为 Prompt、Tool、Extension、Broker 与 OS 建立分层安全边界;
- 设计 Abort、Timeout、Retry 与 Idempotency 的组合;
- 使用 Faux Provider 和固定 Event Sequence 测试,不消耗真实模型 Token;
- 分别测试 Skill 的内容契约与 Extension 的运行时契约;
- 建立评估、诊断、兼容性、打包与回滚门禁。
2. 生产化不是一个开关
flowchart TD
P["Prompt / Skill rules"] --> X["Extension Gate"]
X --> A["Approval"]
A --> B["Final Tool Broker Policy"]
B --> S["OS / Container Sandbox"]
S --> AU["Audit / Recovery"]
上层规则改善行为,下层边界限制能力:
- Prompt/Skill:告诉模型应该怎样做;
- Extension Gate:在受控执行链中检查/转换;
- Approval:让用户对具体 Canonical 动作做决定;
- Broker:以服务端规则重新验证;
- Sandbox:限制进程实际能访问的文件、网络和能力;
- Audit:为恢复、对账和责任追踪提供证据。
任何一层都不能单独代替全部安全设计。
3. Threat Model 先于测试用例
先列资产与攻击面:
| 资产 | 主要风险 | 最终防线 |
|---|---|---|
| Workspace 文件 | 越界读写、路径穿越 | Canonical Path + Broker + Sandbox |
| Shell | 命令注入、继承环境 | Structured Policy + Env Allowlist + Sandbox |
| API Key | Prompt/ToolResult 泄漏 | Auth Store + Redaction + Process Boundary |
| Session | 篡改、跨租户读取 | File Permission + Tenant Binding |
| Extension | 任意本地代码 | 来源固定、审查、隔离、最小权限 |
| Approval | UI 欺骗、参数替换 | Canonical Args + One-shot Capability |
| Provider Response | Prompt Injection | Trust Boundary + Tool Final Policy |
ExtensionAPI、Project Trust、TypeScript 类型与子进程都不等于 OS Sandbox。
4. 权限模型
不要只问“这个 Tool 能不能用”,还要问:
谁
在什么 Session/Workspace
对哪个 Canonical Resource
执行什么动作
在什么时间窗口
可以执行几次
一次性 Capability 可以包含:
interface ToolCapability {
toolCallId: string;
sessionId: string;
action: "write";
canonicalTarget: string;
argsHash: string;
expiresAt: number;
remainingUses: 1;
}
这是宿主设计。真正执行前,Broker 重新验证全部字段;不要只相信 UI 返回 approved: true。
5. Timeout、Abort 与 Cleanup
三者含义不同:
- Timeout:宿主不愿再等;
- Abort:请求底层停止;
- Cleanup:释放已经创建的资源。
timeout
→ abort controller
→ driver observes signal
→ process/fetch stops
→ finally cleanup
→ return explicit interrupted result
只用 Promise.race() 会停止等待,却不一定停止底层工作。Tool/Extension 必须把 Signal 传到
Fetch、Child Process、Database Driver 或 Remote Executor。
Pi 的取消也不是一个总开关:AgentSession.abort() 会停止 Auto-retry 和当前 Agent Run,并
等待 Agent Idle;独立 Bash、Compaction、Branch Summary 分别使用 abortBash()、
abortCompaction()、abortBranchSummary()。测试必须按操作类型调用对应取消 API,并验证
其 Signal 到达底层进程。
6. 两层模型 Retry Budget
Pi v0.82.0 有两层模型重试:
| 层 | 配置/行为 |
|---|---|
| Provider Transport Retry | retry.provider;处理可重试传输错误,可读取 Retry-After |
| AgentSession Auto-retry | retry.enabled/maxRetries/baseDelayMs;最终 Assistant Error 后延迟并 continue(),发 auto_retry_* Event |
两层可能叠加,所以次数、总时间、Token 与成本要分别计算,也要分别验证 Abort。
两层共同满足:
- 操作可安全重试,或有 Idempotency Key;
- Retry 次数和总时间有上限;
- Abort 后不再重试;
- 用户能看见正在恢复;
- 最终失败会收敛到 Settled/Diagnostic。
只有 Provider Transport Retry 能读取 Response Header 并尊重 Retry-After。AgentSession
Auto-retry 拿不到这个 Header,只使用 baseDelayMs * 2^(attempt-1) 指数退避。
Provider/Auto-retry 与 Tool 副作用重试要分开。不要因为 Provider 暂时失败,自动重放一个可能已 写入成功的 Tool。
7. Idempotency 与不确定结果
最危险的状态不是“失败”,而是:
后端可能已经成功,但桌面进程在收到确认前崩溃。
用业务 ID 连接:
runId + toolCallId + operationId
恢复后先查询 Audit/Backend:
- 已成功:恢复为 Completed;
- 明确未执行:允许重新提交;
- 无法判断:标为 Indeterminate,要求人工确认;
- 可补偿:显示补偿动作,而不是静默回滚。
Exactly-once 不能靠 RPC Command ID 或再次发送相同 Prompt 获得。
8. Session 与多会话隔离
测试两个并发 Session:
- Extension Closure 是否串状态;
- Module Singleton 是否按 Tenant/Workspace Key;
- Approval 是否路由到正确 Window;
- Abort A 是否影响 B;
- Model 切换是否泄漏;
- Session Entry 是否写入错误文件;
- Shared ModelRuntime/Auth 是否只有预期共享。
如果要隔离 Extension Instance,每个 Session 需要独立 ResourceLoader/Services;只创建两个 AgentSession 但复用同一个 Extension Runtime,并不会自动隔离。
9. Token、Context 与成本
记录三个层次:
| 层次 | 用途 |
|---|---|
| Turn Usage | 单次 Provider 调用与 Retry |
| Agent Run Usage | 一次用户任务的多 Turn 总计 |
| Session Usage | 长期趋势与预算 |
还要记录:
- Provider/Model;
- Input/Output/Cache Token;
- Tool Nested Model Usage(若有);
- Compaction 前后 Context Size;
- Estimated 与 Final 的区别;
- Retry 造成的额外成本。
成本告警不要阻塞关键 Session Flush;但超预算时应在下一次 Provider 调用前由宿主 Policy 阻止或要求确认。
10. 测试金字塔
flowchart TD
U["Pure Unit<br/>schema/policy/reducer"] --> F["Faux Agent Loop"]
F --> E["Extension/Skill Contract"]
E --> S["Session/Recovery"]
S --> D["Desktop Bridge"]
D --> R["Real Provider Smoke"]
越靠上越快、越确定、越便宜。真实 Provider 测试只保留少量 Smoke/Eval,不能把所有回归 都建立在外部模型上。
11. Pure Unit Test
优先测试纯函数:
- Path Canonicalization;
- SQL/Command Risk Classifier;
- Approval Capability 校验;
- Tool Schema;
- Event Reducer;
- Redaction;
- Retry Budget;
- Version Matrix。
这些测试不需要创建 AgentSession。
12. Faux Provider 测 Agent Loop
Pi 仓库内部 Test Harness 使用 Faux Provider 与预设 Assistant Response,可以确定性地产生:
text
tool call
parallel tool calls
provider error
thinking
stop
应用可以建立等价公开夹具:
import { join } from "node:path";
import {
fauxAssistantMessage,
fauxProvider,
fauxText,
fauxToolCall,
} from "@earendil-works/pi-ai";
import {
createAgentSession,
ModelRuntime,
SessionManager,
} from "@earendil-works/pi-coding-agent";
const faux = fauxProvider();
const modelRuntime = await ModelRuntime.create({
authPath: join(fixturePath, "auth.json"),
modelsPath: null,
});
modelRuntime.registerNativeProvider(faux.provider);
faux.setResponses([
fauxAssistantMessage(
fauxToolCall("read", { path: "logs/error.log" }),
{ stopReason: "toolUse" },
),
fauxAssistantMessage(
fauxToolCall("controlled_write", {
path: "config.json",
content: "{}",
}),
{ stopReason: "toolUse" },
),
fauxAssistantMessage(fauxText("修复完成")),
]);
const { session } = await createAgentSession({
cwd: fixturePath,
sessionManager: SessionManager.inMemory(fixturePath),
modelRuntime,
model: faux.getModel(),
customTools: [readTool, controlledWriteTool],
});
try {
await session.prompt("读取错误日志并修复配置");
// 断言事件、工具调用、ToolResult 与 Session Entry。
} finally {
session.dispose();
}
断言 Event Order、Tool Execute Count、ToolResult、最终消息与 Session Entry。不要在测试中
调用真实付费 Provider。fauxProvider() 是不修改全局 API Registry 的现代组合;要把它的
Native Provider 注册进被测 ModelRuntime,再把同一 ModelRuntime 与 faux.getModel() 传给
AgentSession。Legacy registerFauxProvider() 只注册 Compat 全局 API Registry,不能单独让
独立 ModelRuntime 认识 Faux Provider。
测试使用 Fixture Auth Path 和内存 Model Store,避免读取真实用户的认证、模型与缓存;结束时
还要 Dispose Session,清理监听器和会话资源。
并行 Event Order 还要按配置断言:默认 Tool Batch 并行,但全局
toolExecution === "sequential",或批次中任一 Tool 的
executionMode === "sequential",都会让整批顺序执行。并行时
tool_execution_end 可按完成顺序到达,最终 ToolResult Message 则恢复为模型 ToolCall 顺序。
13. Skill 测试
Skill 需要三层测试。
静态结构
- Frontmatter/Name/Description;
- 触发摘要是否清楚;
- 依赖文件是否存在;
- 相对路径是否能解析;
- 指令是否要求未提供的 Tool;
- 是否意外包含 Secret/绝对机器路径。
触发与读取
脚本化 Faux 可验证:
- 显式 Skill 展开;
- 自动路径中模型发出的 Read 请求;
- Tool/Event 编排;
- 读取了完整
SKILL.md; - 需要的 Reference 按顺序读取;
- 输出/动作满足约束。
但 Faux Response 是预先写死的,不能证明模型“应触发时能识别、不相关时不误触发”。真正的 触发准确率必须进入固定模型的 Golden Eval 或少量 Real-provider Test。
安全与回归
- 外部内容不能覆盖 System/Host Policy;
- 缺文件时 Fail 可见;
- Tool 被禁用时有合理降级;
- Skill 更新后 Golden Task 仍通过。
Skill 是文本工作流,测试重点是“模型在给定工具和上下文下是否遵守契约”,不是只校验文件 能被 Loader 发现。
14. Extension 测试
至少覆盖:
- Factory 成功与失败;
- Pre-bind Provider/Flag 副作用;
- Handler 顺序与分类型错误语义;
tool_callBlock/Transform/Throw;tool_resultPatch/Redaction,以及宿主 Timeout/Worker Isolation;- Abort 传递;
- Reload Shutdown/Cleanup;
- New/Resume/Fork State Reconstruction;
- EventBus Unsubscribe;
- 多 Session/Module Singleton;
- Tool/Flag/Command 冲突;
- Pi 版本兼容。
ExtensionRunner 没有内置 Handler Timeout;一个永不 Resolve 的 Handler 会一直阻塞。测试中
要验证宿主 Timeout/进程终止策略。错误语义也不统一:tool_result Throw 会被记录后继续
后续 Handler;tool_call Throw 会终止后续 Hook,并最终变成阻止工具执行的 Error Result。
安全关键 Extension 加载失败时,要测试“宿主确实阻止高风险动作”,而不是只断言出现一条 Warning。
15. Session 与 Replay 测试
固定一份 Event/Entry Fixture,测试:
- JSONL 打开与恢复;
- Branch 切换;
- Fork Before/At;
- Compaction Summary;
- Custom Entry;
- Tool Details State;
- 中断的 Assistant/Tool;
- 文件尾损坏;
- CWD/Trust Replacement;
- 旧 Generation Event 被拒绝。
generation、runtimeId、runId、messageUiKey、operationId 以及 Extension
Source/Version 多数不是 AgentSessionEvent 自带字段。它们由 Desktop Controller/Bridge
生成并包装进 Fixture;测试要同时验证 Envelope 生成与 Reducer,而不是在 Pi Event 类型里
寻找这些字段。
Replay 的目标是重建应用状态,不是重新执行 Tool。任何回放逻辑都必须保证 Side Effect 为零。
16. Desktop Bridge 测试
SDK
- Session Replacement 前拆旧 Bridge;
- 新 Session 先订阅再绑定 Extension;
runtime.dispose()发 Shutdown;- Store 不保存 Session Object;
- Window Close 取消 Approval。
RPC
- LF-only Framing;
- Command Response 乱序;
- JSON Parse Error;
- stdout/stdin Backpressure;
- Extension UI Request/Response;
- Worker Exit;
- Pending 清除;
- Snapshot + Live Event Rehydrate;
- 无版本握手时的外层版本门禁。
测试时随机切分 JSON Byte Chunk,不能假设一行恰好等于一次 Pipe Read。
17. Evals:测“效果”,不是测代码分支
建立 Golden Task Dataset:
task
workspace fixture
allowed tools
expected invariants
forbidden actions
quality rubric
cost/latency budget
示例:
给定一个缺少端口配置的项目,Agent 应先读取日志和配置,提出最小修改;未经批准不能写入 Workspace 外部,也不能运行网络命令。
评估维度:
- 任务完成率;
- Tool 选择;
- 权限违规率;
- 不必要写入;
- 引用证据正确性;
- Turn/Token/Cost;
- 延迟;
- 恢复成功率。
模型输出非确定,所以比较分布和不变量,不要只 Snapshot 一段自然语言。
18. 日志与 Trace
每条记录至少带:
timestamp
runtimeId / generation
sessionId / runId / turn
messageUiKey / toolCallId / operationId
provider / model
extension source/version
event / duration / result class
默认不记录:
- API Key/OAuth Token;
- 完整 Environment;
- 未脱敏 Prompt/Tool Content;
- Approval 中的敏感参数;
- 用户本地文件正文。
需要内容级调试时,使用显式 Opt-in、短期保留和访问控制。
19. 诊断不是一个 errors 数组
Controller 聚合:
- Resource Loader 的 Extension Errors;
- Skill/Prompt/Theme Diagnostics;
- Runtime Diagnostics;
- Model Fallback Message;
- Provider/Auth Error;
- Tool Result Error;
- Session Recovery/Replacement Exception;
- RPC Parse/Exit;
- Host Policy/Audit Failure。
每条诊断应包含 Scope、Severity、Recoverable、User Action 与 Correlation ID。
20. 打包
SDK/Electron
- 固定 Node 与 Pi Package 版本;
- 审查 Production Dependencies;
- 将 Auth/Session 放入正确用户目录;
- 不把开发环境变量带入发布包;
- 为 Native Addon/Child Process 建签名策略。
RPC/Sidecar
- 把固定版本 Worker 随 App 打包;
- 不从任意系统 PATH 发现 Pi;
- 校验 Binary Hash/Signature;
- 明确 CWD、Environment 与 Sandbox Profile;
- 捕获 stdout/stderr/Exit;
- Worker 与 App 使用同一兼容矩阵。
当前 Pi RPC 没有内置 Version/Capabilities Handshake,所以版本校验要在协议外完成。
21. 升级与兼容性
每次升级 Pi:
read changelogs
→ type/build checks
→ skill static tests
→ extension contract tests
→ session fixture replay
→ SDK/RPC bridge tests
→ golden eval comparison
→ canary
→ rollback ready
重点观察:
- Agent/Session Event 顺序;
- Tool Result Schema;
- Extension Handler 错误语义;
- Reload/Replacement;
- Provider Model Metadata;
- RPC Union;
- Session Entry/Compaction;
- Node Runtime 要求。
TypeScript 编译通过不代表行为兼容。
22. 发布门禁
| 门禁 | 必须通过 |
|---|---|
| 安全 | 越权/注入/审批替换测试 |
| 确定性 | Unit、Faux、Replay |
| 生命周期 | Abort、Reload、Dispose、Crash |
| 并发 | Parallel Tool、多 Session、多窗口 |
| 质量 | Golden Evals 不低于基线 |
| 成本 | Token/Latency 在预算内 |
| 兼容 | Pi/Node/Worker/Extension Matrix |
| 运维 | Log、Diagnostic、Canary、Rollback |
安全关键失败不能用“已知问题”静默放行。
23. 综合验收测试
发布前完成以下 12 项:
- Text + Thinking Streaming;
- 模型切换与能力降级;
- Read Tool 与 Parallel Tool;
- Write Approval 通过、拒绝、超时和窗口关闭;
- Agent Run、独立 Bash、Compaction、Branch Summary 分别取消,并传播到对应底层进程;
- Steering/Follow-up Queue;
- Session Save/Resume/Branch;
- Compaction 后历史仍可回看;
- Skill 触发、读取、遵循与缺文件诊断;
- Extension Reload、冲突、失败与 Cleanup;
- Worker Crash/Rehydrate/Indeterminate Tool;
- 两 Session 两窗口无状态或审批串线。
每项都要有自动化证据;真实 Provider 只补充少量端到端 Smoke。
24. 常见误区
“有 Approval 就不用 Sandbox”
错误。Approval 可能被误路由,参数也可能在批准后改变。
“Abort Promise 返回就代表副作用停止”
错误。底层 Driver 必须观察 Signal,已发生动作还要对账。
“Skill 是 Markdown,不需要测试”
错误。它会改变模型的 Tool 选择和执行流程。
“Faux Provider 不能测试真实 Agent”
错误。它非常适合测试控制流与不变量;真实 Provider 用于补行为评估。
“版本固定后就不需要兼容测试”
错误。升级、Session 恢复与 Extension 包仍会跨版本发生。
25. 本章重点总结
- 生产安全是 Prompt、Extension、Approval、Broker、Sandbox 与 Audit 的分层组合;
- Timeout 必须触发 Abort,Abort 必须传到底层,Cleanup 必须幂等;
- 副作用使用 Idempotency Key,并把未知结果标为 Indeterminate;
- Unit/Faux/Replay 测确定控制流,Golden Eval 测模型效果;
- Skill 测发现、读取、遵循与安全,Extension 测错误、生命周期、冲突与并发;
- Session Replay 只重建状态,绝不重新执行 Tool;
- Diagnostics 来自多个层,不是一个统一数组;
- 打包固定 Worker/依赖,升级运行契约测试并准备 Canary/Rollback。
26. 练习
- 为什么 Approval Capability 要绑定
argsHash和toolCallId? Promise.race()超时为什么可能留下后台副作用?- 哪些测试最适合 Faux Provider,哪些必须用真实模型?
- Skill 的静态测试与 Golden Task 测试分别发现什么问题?
- Worker 崩溃后 Tool 状态为什么可能是 Indeterminate?
- Session Replay 为什么不能调用 Tool?
- 设计一份包含两个并行 Session 的隔离测试。
- 当前 RPC 没有版本握手时,桌面应用怎样固定兼容版本?