Pi 发出的是运行时 Event,桌面 UI 需要的是可渲染 State:
message_update Event ≠ 一个聊天气泡组件
tool_execution_start ≠ 一个审批弹窗
session_start ≠ 一个窗口
本章建立中间的 State Adapter,不讨论 Pi TUI。
1. 总体分层
flowchart LR
P["Pi AgentSession / RPC Events"] --> A["Event Adapter"]
A --> R["Pure Reducer"]
R --> S["Desktop Store"]
S --> V["React / Vue / SwiftUI"]
V --> I["User Intent"]
I --> C["Agent Controller"]
C --> P
View 不调用 Pi Private API;Reducer 不执行 Tool/Network;Controller 不保存渲染细节。
2. 顶层状态
interface DesktopAgentState {
generation: number;
session: SessionViewState;
conversation: ConversationState;
run: RunState;
tools: Record<string, ToolExecutionState>;
approvals: Record<string, ApprovalState>;
diagnostics: DiagnosticState[];
}
generation 在 Worker Restart 或 Session Replacement 时增加,用于拒绝旧 Event。
3. Session 状态
interface SessionViewState {
id: string;
file?: string;
name?: string;
cwd: string;
model?: ModelSummary;
thinkingLevel: string;
isCompacting: boolean;
pendingMessageCount: number;
}
初始值来自 SDK State/RPC get_state,变化来自 Model、Thinking、Session Info、Compaction 和
Queue Event/Command Result。
但 RPC get_state 的 RpcSessionState 不包含 cwd。RPC 宿主要从 Worker 启动配置、
Session Header 或自己的扩展接口取得 Workspace 路径,再合并进 SessionViewState。
4. Conversation 状态
建议区分:
- Persisted Session Entries;
- Current Agent Context Messages;
- Current Streaming Assistant Draft;
- Pending User Messages;
- Custom/Hidden Messages;
- Tool Result Projection。
interface ConversationState {
history: ConversationItem[]; // 从稳定 Session Entry ID 投影
activeContext: AgentMessage[]; // 当前模型上下文,可能已压缩
provisional: ConversationItem[]; // Live Event,等待 Entry 对账
streaming?: AssistantDraft;
pending: PendingMessage[];
entryCursor?: string;
leafId?: string;
}
不要每个 Delta 都复制完整 Message History。
RPC get_messages 返回的是 session.messages,即当前 Agent Context;Compaction 后它不等于
完整历史,也没有稳定 Entry ID。可靠恢复、Branch 与去重应使用 get_entries 的 entry.id、
since Cursor 和 leafId;get_messages 适合快速恢复当前模型视图。
5. Streaming 状态
interface AssistantDraft {
runId: string;
text: string;
thinking: string;
startedAt: number;
status: "streaming" | "aborted" | "interrupted" | "error";
}
这里的 runId 由桌面 Adapter 为本地 Run 生成,不是 Pi Event 自带字段。
必须先按 event.message.role 分支:User/ToolResult 的 message_start/message_end 更新
Provisional Live Projection,不能覆盖 Assistant Draft;只有 Assistant Message 有
message_update。
Assistant 的 message_start 建 Draft,message_update 合并 Text/Thinking,message_end
用 Final Message Reconcile,再移入 Provisional。普通 Message Event 没有 Session Entry ID;
下一次 get_entries 返回持久 Entry 后,再用内容/顺序做对账,以稳定 Entry ID 替换
Provisional Item 并进入 History。
Event 中也包含当前 Message,应用可按最终对象替换,而不是假设所有 Delta 都完整到达。
6. Thinking 状态
Thinking 是 Assistant Content 的一部分/Streaming Event,不应混进最终可见 Text:
assistant draft
├── text
└── thinking
UI 可折叠/隐藏 Thinking,但 Session/Provider 是否保存、返回何种 Thinking 取决于 Model/API。 不要假设每个 Model 都有同样的 Thinking Event。
7. Run 状态机
stateDiagram-v2
[*] --> Idle
Idle --> Starting: prompt accepted
Starting --> Streaming: agent_start/message_start
Streaming --> Tooling: tool_execution_start
Tooling --> Streaming: tool result / next turn
Streaming --> Settling: agent_end
Tooling --> Settling: agent_end
Settling --> Idle: agent_settled
Streaming --> Aborting: user abort
Tooling --> Aborting: user abort
Aborting --> Idle: agent_settled
agent_end 不直接置 Idle;agent_settled 才表示没有自动 Retry/Compaction/Queue Continuation。
8. Tool 状态
interface ToolExecutionState {
toolCallId: string;
name: string;
rawArgs: unknown;
progress?: unknown;
result?: unknown;
isError?: boolean;
phase:
| "preflight"
| "running"
| "completed"
| "blocked"
| "interrupted"
| "unknown"
| "error";
}
按 toolCallId Upsert:
- Start:Create;
- Update:Merge Progress;
- End:Finalize。
Parallel Tool 的 Update/End 可交错,不能用“最后一个 Tool”单变量。
9. Raw Arguments 与最终参数
tool_execution_start.args 是 Prepare/Validation 前 Raw Arguments。审批链中 Input 还能变化。
UI 若展示审批,必须使用 Approval Request 自己携带的 Canonical Arguments;Audit 最终参数由 最后受控 Gate/Backend 记录。不要把 Tool Start Args 误标为“最终执行命令”。
10. Permission 状态
Approval 不是标准 AgentSession Event,而来自宿主 Policy/Extension UI Bridge。下面是宿主 自定义的 Approval Envelope:
interface ApprovalState {
requestId: string;
toolCallId: string;
toolName: string;
canonicalArgs: unknown;
risk: string;
status:
| "pending"
| "approved"
| "rejected"
| "expired"
| "cancelled";
}
Window Close、Abort、Worker Exit、Timeout 都必须结束 Pending Approval。
标准 RPC extension_ui_request 的 Confirm 只有 id/method/title/message/timeout,并不携带
toolCallId、toolName、canonicalArgs 或 risk。宿主必须在 Backend 保存
UI Request ID → Canonical Tool Call 的映射,或定义单独的受控 Approval Protocol;不能从
显示文本反解析最终执行参数。
11. Event 到 State 的映射
| Event | Reducer 动作 |
|---|---|
agent_start |
Run → Streaming |
message_start |
Create Draft/Item |
message_update |
Merge Text/Thinking |
message_end |
Reconcile Final Message |
tool_execution_start |
Upsert Tool Preflight |
tool_execution_update |
Merge Progress |
tool_execution_end |
Finalize Tool |
turn_end |
更新 Turn Index/Usage |
agent_end |
Run → Settling |
agent_settled |
Run → Idle |
| Retry/Compaction Event | 显示恢复状态 |
thinking_level_changed |
更新 Thinking Header |
| Model Command Response/State Query | 更新 Model Header |
同一个低层 Event 可以更新多个 Slice,但 Reducer 必须保持纯函数。
model_select 是 Extension Runner Event,不属于公共 AgentSessionEvent/RPC Event Stream。
RPC 客户端应根据 set_model/cycle_model Response 或重新 get_state 更新;若 Extension
主动切换模型,则需要宿主自定义通知或重新查询。
12. Event Ordering 与幂等
同一 Session Event Stream 有顺序,但桌面 Bridge、Batch Render、Worker Restart 会引入:
- Duplicate;
- Late Event;
- Lost Delta;
- Old Generation;
- Rehydrate Snapshot 与 Live Event 交错。
策略:
- Generation Token;
- Monotonic Local Sequence;
- ToolCall ID;
- Final Message Reconcile;
- Snapshot Applied Barrier;
- Reducer Idempotency。
这些信封字段不是 Pi Event 原生字段。runtimeId、generation、localSequence、runId、
windowOwner 都由 Adapter 生成;sessionId 也需要 Adapter 补到每条 Live Event 上。
13. Session Replacement
freeze old generation
→ unsubscribe old session
→ create/apply new runtime
→ increment generation
→ subscribe new events into buffer
→ get state/messages + get_entries
→ record get_entries response as snapshotBarrier
→ apply snapshot by stable entry id
→ discard/reconcile message events at or before barrier
→ replay only events after barrier by local sequence
→ open live gate
这能避免 Snapshot 与 Subscription 之间的 Event Gap。
完整历史首次使用全量 get_entries,增量恢复可用 get_entries({ since: entryCursor })。Local
Sequence 要分配给每个入站 Record(包括 Snapshot Response),用 Response 所在 Sequence
形成 Snapshot Barrier。Barrier 前的无 ID Message Event 已可能包含在 Snapshot 中,必须丢弃
或按内容/顺序与 Snapshot 对账;只 Replay Barrier 后 Event。持久 Entry 去重依靠稳定 Entry
ID,Local Sequence 则负责划定 Barrier 和排列 Buffer,二者不能互相替代。
14. RPC Reconnect
Worker Restart 后:
- 清理 Pending Command/Approval;
- 标记当前 Draft Interrupted;
- 增加 Generation;
- 重启并打开 Session;
get_state/get_messages恢复当前运行上下文;- 全量
get_entries或按entryCursor增量恢复历史与 Branch; - 按 Entry ID 与 Buffered Local Sequence Reconcile;
- 恢复 Live Event。
不要把崩溃前 running Tool 自动改成 Success;标为 Unknown/Interrupted,并查后端 Audit。
15. React 消费
const text = useAgentStore(
(state) => state.conversation.streaming?.text,
);
Selector 粒度要小。Text Delta 可在 Adapter 中按 Animation Frame Batch,避免每个 Token 重绘 整个 Conversation。
View 发送 Intent:
controller.prompt(text);
controller.abort();
controller.resolveApproval(id, decision);
16. Vue 消费
使用 Reactive Store,但仍保持同一 Reducer Contract:
const streamingText = computed(
() => store.state.conversation.streaming?.text,
);
不要在 Component Watcher 中直接 Subscribe Pi;生命周期与 Window Routing 交给 Controller。
17. SwiftUI 消费
Bridge 把 JSON/SDK Event 转为 Swift Enum,再在 Main Actor 更新 Observable State:
RPC Reader Task
→ Codable Event
→ Agent Reducer
→ @Observable / ObservableObject
→ View
Protocol Reader 不应在 Main Thread 做大 JSON Processing;State Commit 回 Main Actor。
18. 多窗口 Routing
每条 Action/Event 带:
runtimeId
sessionId
windowOwner
generation
以上都是宿主 Event Envelope 字段,不是 Pi Event 类型的固定成员。
Approval Dialog 只发给拥有该 Runtime 的 Window。若 Owner Window 关闭:
- 转移 Ownership 并重建 UI;
- 或 Cancel Pending Approval;
- 不静默交给另一个无上下文窗口。
19. Usage 与 Cost 状态
Final Assistant Message/Session Stats 可带 Usage。Store 分:
- Current Turn;
- Current Session;
- Provider/Model;
- Tool Nested Usage;
- Estimated Context Usage。
Streaming 中的 Estimate 与 Final Usage 不同,UI 要标记状态,不把临时值当账单。
20. Error 状态
错误不是一个 String:
interface DiagnosticState {
id: string;
scope:
| "provider"
| "tool"
| "extension"
| "session"
| "rpc"
| "host";
severity: "info" | "warning" | "error";
recoverable: boolean;
message: string;
}
Provider Error 可 Retry;Tool Error 属 Tool Card;Extension Load Error 属启动诊断;RPC Exit 属 Worker State。
21. Store 不应保存什么
- API Key/OAuth Token;
- 未脱敏完整 Environment;
- 不受限的大 Tool Output Duplicate;
- Mutable AgentSession Object;
- UI Component Instance;
- 无 Generation 的 Callback。
Store 保存可序列化 View State,敏感能力留在 Controller/Backend。
22. 一个 Reducer 示例
function reduce(
state: DesktopAgentState,
envelope: EventEnvelope,
): DesktopAgentState {
if (envelope.generation !== state.generation) {
return state;
}
switch (envelope.event.type) {
case "tool_execution_update":
return mergeToolProgress(state, envelope.event);
case "agent_settled":
return { ...state, run: { phase: "idle" } };
default:
return state;
}
}
这是宿主示例,不是 Pi API。
23. 测试 State Adapter
用固定 Event Sequence:
- Text-only;
- Thinking + Text;
- Parallel Tools;
- Blocked Tool;
- Abort;
- Retry;
- Compaction;
- Follow-up;
- Session Replacement;
- Worker Crash/Rehydrate。
对每一步 Snapshot 断言,不需要真实模型。
24. 常见误区
“message_update 就直接 append delta”
丢 Event 或重连时会错;最终要用 Message End/Snapshot Reconcile。
“一个 run 只有一个 Tool”
错误,Parallel Tool 需要 Map。
“agent_end 就置 Idle”
错误,用 agent_settled。
“Approval 是 Tool Start Event”
错误,它是独立 Policy/UI Channel。
“组件自己订阅 Session 最简单”
Session Replacement、多窗口与 Cleanup 会失控。
25. 本章小结
- Pi Event 先经过 Adapter/Reducer,再成为 Desktop View State;
- Conversation 分 Committed、Streaming Draft 与 Pending;
- Text 与 Thinking 分开存储;
- Run 用 agent_settled 进入 Idle;
- Parallel Tool 按 toolCallId 建 Map;
- Approval 是独立 Channel,并绑定 Canonical Arguments;
- Generation、Sequence 与 Reconcile 处理 Replacement/Restart;
- React/Vue/SwiftUI 共享同一框架无关 State Contract;
- Store 只保存可序列化、已脱敏 View State;
- State Adapter 可完全用固定 Event Sequence 测试。
26. 自测
- 为什么 View 不应直接持有 AgentSession?
- Message Delta 为什么还需要 Final Reconcile?
- Thinking 与 Text 为什么分开?
- Parallel Tool 用什么 Key 管理?
- Approval 为什么不是 Tool Start Event?
- Generation Token 解决什么问题?
- Session Replacement 如何避免 Snapshot/Event Gap?
- 哪些敏感数据不应进入 Store?