返回课程首页

33

桌面 Agent 的应用状态设计

把对话、流、工具、权限和会话事件映射成 UI 状态。

源码基线:Pi v0.82.0 · commit 518855d

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_stateRpcSessionState 不包含 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_entriesentry.idsince Cursor 和 leafIdget_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,并不携带 toolCallIdtoolNamecanonicalArgsrisk。宿主必须在 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 原生字段。runtimeIdgenerationlocalSequencerunIdwindowOwner 都由 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 后:

  1. 清理 Pending Command/Approval;
  2. 标记当前 Draft Interrupted;
  3. 增加 Generation;
  4. 重启并打开 Session;
  5. get_state/get_messages 恢复当前运行上下文;
  6. 全量 get_entries 或按 entryCursor 增量恢复历史与 Branch;
  7. 按 Entry ID 与 Buffered Local Sequence Reconcile;
  8. 恢复 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. 自测

  1. 为什么 View 不应直接持有 AgentSession?
  2. Message Delta 为什么还需要 Final Reconcile?
  3. Thinking 与 Text 为什么分开?
  4. Parallel Tool 用什么 Key 管理?
  5. Approval 为什么不是 Tool Start Event?
  6. Generation Token 解决什么问题?
  7. Session Replacement 如何避免 Snapshot/Event Gap?
  8. 哪些敏感数据不应进入 Store?