一. 相关结构

1. SessionManager (会话管理session-thread-turn)

pub struct SessionManager {
    sessions: RwLock<HashMap<String, Arc<Mutex<Session>>>>,
    thread_map: RwLock<HashMap<ThreadKey, Uuid>>,
    undo_managers: RwLock<HashMap<Uuid, Arc<Mutex<UndoManager>>>>,
    hooks: Option<Arc<HookRegistry>>,
}
这是 agent 模块的"会话账本"。它本身不持有对话内容——对话内容在 Session { threads: HashMap<Uuid, Thread> { turns: Vec<Turn> } } 里——它只负责索引和外挂能力的查找表。

  1. sessions: RwLock<HashMap<String, Arc<Mutex<Session>>>>

  ▎ Key = user_id,Value = 共享会话句柄

  这是身份到会话的映射。 多用户多会话是核心:

  - 同一个 user_id(不分 channel)→ 同一个 Arc<Mutex<Session>>。test_get_or_create_session 验证了这一点。
  - 不同 user_id → 不同 Session。

  sessions:
    "user-alice"  -> Arc<Mutex<Session{id: S1, threads: {T1, T2, T3}}>>
    "user-bob"    -> Arc<Mutex<Session{id: S2, threads: {T4}}>>

  几个值得注意的设计点:

  - 双重检查锁(DCL):get_or_create_session 先 read lock 找;找不到再 write lock;拿到写锁后再查一次(session_manager.rs:65)。这是标准 Java/C++ 风格 DCL,在 Rust 里也是必要的——光靠 if let Some
  然后直接 insert 会因为两个并发请求都 miss read lock 而重复创建。concurrent_get_or_create_same_user_returns_same_session 这个测试就是为这个不变量存在的。
  - Arc<Mutex<Session>> 而不是 Arc<RwLock<Session>>:因为 Session 几乎所有操作都是 read-modify-write(加 turn、改 active_thread、改 last_active_at),所以读多写少这个 RwLock 优势拿不到,反而 Mutex
  更简单、不会有人写出"读锁里 await 别的写锁"这种死锁模式。
  - 1000 阈值警告(session_manager.rs:74-80):超过 1000 个活跃 session 时,每 100 个打一条 warn——这是廉价的对运维友好的信号,不是 hard cap。
  - Prune:prune_stale_sessions 遍历 read lock,对每个 session 用 try_lock() 试一下(拿不到就跳过——有人在用就保留),按 last_active_at < cutoff 过滤,然后批量
  remove。这是一个"尽力而为的清理器"——永远不会把正在用的 session 杀掉。

  1:1 不是 1:N:sessions 这层不分 channel,分 channel 的事是下一个字段的活。

  ---
  2. thread_map: RwLock<HashMap<ThreadKey, Uuid>>

  ▎ Key = (user_id, channel, external_thread_id),Value = 内部 thread UUID

  这是 channel 维度的 thread 索引。 同一个 user_alice 在 Telegram、Slack、Web Gateway 上各自有独立的对话线——它们都活在 alice 的同一个 Session 里(因为 sessions 是按 user_id 分),但 thread_map 让每个
  channel 路由到不同的 Thread。

  thread_map:
    ("alice", "telegram", Some("chat-123")) -> T1
    ("alice", "slack",    Some("C123.456")) -> T2
    ("alice", "gateway",  None)             -> T3   // 没显式 thread_id 的默认线
    ("alice", "telegram", None)             -> T4   // telegram 默认线(独立于 gateway 默认线)

  几个关键设计点:

  - ThreadKey 三元组:(user_id, channel, external_thread_id)。
    - user_id:多用户隔离。
    - channel:多端隔离——同一个用户在 web 和 telegram 是不同的 thread。
    - external_thread_id: Option:None 也是一个合法的 key(默认线),而且 None ≠ Some(""),这是有意为之(test_resolve_thread_none_vs_some_external_id 验证)。
  - None 不触发 UUID adoption(session_manager.rs:170-174 + test_resolve_thread_with_none_external_thread_id_does_not_adopt):这是为了保护一个不变量——"没传 external_id 的调用方永远拿到它自己创建的默认
  thread,不会被某个并行 hydrate 出来的 UUID 拐走"。这是个微妙的安全洞的修补,注释里点名了。
  - register_thread(:246):这是反向操作——给一个已经存在但没在 map 里的 thread(比如从 DB hydrate 出来的、chat_new_thread_handler 直接 sess.threads.insert(...) 的)建立 map 入口。or_insert_with
  让它幂等(test_register_thread_idempotent),用 entry().or_insert_with 而不是 insert 是因为后者会覆盖已有的 undo manager(覆盖就丢数据了)。
  - UUID adoption 路径(:152-198):一个相对复杂的兜底——如果 external_thread_id 是个合法 UUID 且 thread_map 里没人认领它、且 session.threads 里确实有这个 UUID 的 Thread,那 map
  把它认领回去。test_register_thread_preserves_uuid_on_resolve 这条测试的注释说"this was the root cause of the wrong conversation
  bug"——就是说之前漏了这个路径导致回复发到错的对话去了。resolve_thread_with_parsed_uuid 是为审批路由优化而加的:approval 路径已经验证过 UUID 是合法的,不需要重复 parse。
  - Prune 时按 user 整体抹掉(:386):thread_map.retain(|key, _| !stale_users.contains(&key.user_id))——不需要逐 thread 判,因为它和 sessions 里的用户生命周期一致。

  ---
  3. undo_managers: RwLock<HashMap<Uuid, Arc<Mutex<UndoManager>>>>

  ▎ Key = thread Uuid,Value = 该 thread 的 undo/redo 状态机

  这是 thread 维度的 undo 能力外挂。 为什么单独拎出来而不是塞进 Session.threads[uuid].undo_manager?

  - Session 在锁内,UndoManager 也需要锁——把两个 mutex 字段放在同一个 struct 上 = 经典的可重入/借锁死锁陷阱。
  - prune 时独立 remove(:389-394):cleanup 路径需要按 thread_id 直接抹掉,外部 map 拿得到 key(stale_thread_ids),不用锁整个 Session。
  - UndoManager 维护一个 checkpoints 栈(不是简单一个 prev):最多 20 个快照,超出 FIFO。undo/redo 是栈操作。这个细节不在字段层面暴露,但 test_undo_manager 验证了 get_undo_manager(thread_id)
  两次返回同一个 Arc。

  get_undo_manager 也有 DCL(:279-298):read lock 查 miss → write lock → 再查 miss → insert。和 sessions 一样的并发安全模式。

  一个隐含事实:thread_map 的 key 是 (user, channel, ext_id),undo_managers 的 key 是 Uuid(内部 thread id)。这两个 map 的生命周期是独立的——thread_map 按 user 整体 prune;undo_managers 按 thread_id
  逐个 prune。所以 prune 一个用户时是"先把 thread_id 收齐再批量删 undo_managers"——见 :332-395 的两段循环。

  ---
  4. hooks: Option<Arc<HookRegistry>>

  ▎ None 意味着不挂任何 hook;Some 意味着触发 OnSessionStart / OnSessionEnd

  这是给 SessionManager 接钩子的插槽。

  - Option 是有意的(不是 architecture.md 规则 2 警告的那种"运行时假装可缺"的 smell):测试构造的 SessionManager::new() 不需要 hooks;只有 AppBuilder 把它组装进 AgentComponents 时才通过 with_hooks()
  注入。这是真正的可选依赖——测试代码大量不挂 hook,照样能跑。
  - fire-and-forget 触发(:83-97 和 :351-371):
    - get_or_create_session 创建完 session 后,tokio::spawn 一个异步任务去 hooks.run(&HookEvent::SessionStart { ... })——不阻塞 session 创建。
    - prune_stale_sessions 删除前,对每个要清理的 session 同样 spawn 触发 HookEvent::SessionEnd { ..., thread_ids }——让 SessionSummaryHook 有机会写完摘要再死。
    - 两处都 if let Err(e) = ... { tracing::warn!(...) },fail-open 行为——hook 出错不会拖垮会话流程,这呼应 HookFailureMode 的讨论。
  - 不在普通 session 查找路径触发(:53-67):只有"创建新 session"才触发 SessionStart;只是 read lock 命中已存在 session 时不触发——否则每次发消息都会 spawn 一个新 hook 任务。这是个有意识的状态机设计。
  - 没有 OnInbound / OnOutbound:那些挂在消息层(Agent/ChatDelegate),不是 SessionManager 的职责。SessionManager 只关心生命周期事件。

  ---
  字段间的总体关系

          ┌─────────────────────────────────────────────┐
          │              SessionManager                 │
          │                                             │
          │   sessions                                  │
          │   ┌──────────────────────────────────────┐  │
          │   │ "alice" -> Arc<Mutex<Session>>       │  │
          │   │            threads: {                │  │
          │   │              T1: { turns: [...] }    │  │  ← 对话内容在这里
          │   │              T2: { turns: [...] }    │  │
          │   │              ...                     │  │
          │   │            }                          │  │
          │   └──────────────────────────────────────┘  │
          │                                             │
          │   thread_map                                │
          │   ┌──────────────────────────────────────┐  │
          │   │ (alice,telegram,"chat-123") -> T1    │  │  ← 路由索引
          │   │ (alice,slack,"C123.456") -> T2       │  │
          │   └──────────────────────────────────────┘  │
          │                                             │
          │   undo_managers                             │
          │   ┌──────────────────────────────────────┐  │
          │   │ T1 -> Arc<Mutex<UndoManager{ckpts[]}>│ │  ← thread 维度的 undo 状态
          │   │ T2 -> Arc<Mutex<UndoManager{...}>   │  │
          │   └──────────────────────────────────────┘  │
          │                                             │
          │   hooks: Some(Arc<HookRegistry>)            │  ← 生命周期信号
          └─────────────────────────────────────────────┘

  四个字段的 key 空间是不重叠的:
  - sessions —— 主体(user 维度)
  - thread_map —— 路由(user × channel × ext_id → 主体内的 thread)
  - undo_managers —— 能力外挂(thread 维度)
  - hooks —— 全局共享信号

  这种"主体一份、索引若干、能力外挂、全局信号"的拆分让 prune/resolve/route/notify 各走各的锁,不会因为一个高频路径(比如 undo)抢锁导致消息路径(比如 resolve_thread)卡死。
/// Key for mapping external thread IDs to internal ones.
#[derive(Clone, Hash, Eq, PartialEq)]
struct ThreadKey {
    user_id: String,
    channel: String,
    external_thread_id: Option<String>,
}

2. Session(一个用户一个会话)

//! Session and thread model for turn-based agent interactions.
//!
//! A Session contains one or more Threads. Each Thread represents a
//! conversation/interaction sequence with the agent. Threads contain
//! Turns, which are request/response pairs.
//!
//! This model supports:
//! - Undo: Roll back to a previous turn
//! - Interrupt: Cancel the current turn mid-execution
//! - Compaction: Summarize old turns to save context
//! - Resume: Continue from a saved checkpoint


/// A session containing one or more threads.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
    /// Unique session ID.
    pub id: Uuid,
    /// User ID that owns this session.
    pub user_id: String,
    /// Active thread ID.
    pub active_thread: Option<Uuid>,
 ▎ active_thread 是 SessionManager 不维护的、"用户当前焦点"这个用户态概念的内存表示。 它不是路由优化,是用户隐式语义的承载——"用户没指定 thread_id 时该往哪发"这个问题的答案。SessionManager 的
  ▎ thread_map 处理显式路由,Session 内部的 active_thread 处理隐式焦点。
    /// All threads in this session.
    pub threads: HashMap<Uuid, Thread>,//threadId对应的具体会话内容
    /// When the session was created.
    pub created_at: DateTime<Utc>,
    /// When the session was last active.
    pub last_active_at: DateTime<Utc>,
    /// Session metadata.
    pub metadata: serde_json::Value,
    /// Tools that have been auto-approved for this session ("always approve").
    #[serde(default)]
    pub auto_approved_tools: HashSet<String>,
}

3. thread(每个用户session可能会有多个会话)

/// A conversation thread within a session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread {
    /// Unique thread ID.
    pub id: Uuid,
    /// Parent session ID.
    pub session_id: Uuid,
    /// Current state.
    pub state: ThreadState,
    /// Turns in this thread.
    pub turns: Vec<Turn>,//所有消息的存储,有多轮
    /// When the thread was created.
    pub created_at: DateTime<Utc>,
    /// When the thread was last updated.
    pub updated_at: DateTime<Utc>,
    /// Thread metadata (e.g., title, tags).
    pub metadata: serde_json::Value,
    /// Pending approval request (when state is AwaitingApproval).
    #[serde(default)]
    pub pending_approval: Option<PendingApproval>,
    /// Pending auth token request (thread is in auth mode).
    #[serde(default)]
    pub pending_auth: Option<PendingAuth>,
    /// Messages queued while the thread was processing a turn.
    #[serde(default, skip_serializing_if = "VecDeque::is_empty")]
    pub pending_messages: VecDeque<String>,pending_messages 是 agent 正在处理 turn 时新到达消息的 FIFO 队列,
避免用户在思考期间发的消息被丢弃——按到达顺序在当前 turn 完成后依次启动新 turn。
    /// Channel that created this thread (for approval authorization).
    #[serde(default)]
    pub source_channel: Option<String>,
}
/// State of a thread.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThreadState {
    /// Thread is idle, waiting for input.
    Idle,
    /// Thread is processing a turn.
    Processing,
    /// Thread is waiting for user approval.
    AwaitingApproval,
    /// Thread has completed (no more turns expected).
    Completed,
    /// Thread was interrupted.
    Interrupted,
}
/// Pending tool approval request stored on a thread.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingApproval {
    /// Unique request ID.
    pub request_id: Uuid,
    /// Tool name requiring approval.
    pub tool_name: String,
    /// Tool parameters (original values, used for execution).
    pub parameters: serde_json::Value,
    /// Redacted tool parameters (sensitive values replaced with `[REDACTED]`).
    /// Used for display in approval UI, logs, and SSE broadcasts.
    #[serde(default)]
    pub display_parameters: serde_json::Value,
    /// Description of what the tool will do.
    pub description: String,
    /// Tool call ID from LLM (for proper context continuation).
    pub tool_call_id: String,
    /// Context messages at the time of the request (to resume from).
    pub context_messages: Vec<ChatMessage>,
    /// Remaining tool calls from the same assistant message that were not
    /// executed yet when approval was requested.
    #[serde(default)]
    pub deferred_tool_calls: Vec<ToolCall>,
    /// First actionable auth prompt already discovered in this turn. Persisted
    /// so approval pauses do not drop the prompt before it can be surfaced.
    #[serde(default)]
    pub selected_auth_prompt: Option<PendingAuthPrompt>,
    /// User timezone at the time the approval was requested, so it persists
    /// through the approval flow even if the approval message lacks timezone.
    #[serde(default)]
    pub user_timezone: Option<String>,
    /// Whether the "always" auto-approve option should be offered to the user.
    /// `false` when the tool returned `ApprovalRequirement::Always` (e.g.
    /// destructive shell commands), meaning every invocation must be confirmed.
    #[serde(default = "default_true")]
    pub allow_always: bool,
}
/// When `tool_auth` returns `awaiting_token`, the thread enters auth mode.
/// The next user message is intercepted before entering the normal pipeline
/// (no logging, no turn creation, no history) and routed directly to the
/// credential store.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingAuth {
    /// Extension name to authenticate.
    pub extension_name: ExtensionName,
    /// When this auth mode was entered. Used for TTL expiry.
    #[serde(default = "Utc::now")]
    pub created_at: DateTime<Utc>,
}

4. turn

/// A single turn (request/response pair) in a thread.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Turn {
    /// Turn number (0-indexed).
    pub turn_number: usize,
    /// Persisted user message ID when this turn has been written to the DB.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub user_message_id: Option<Uuid>,
    /// User input that started this turn.
    pub user_input: String,
    /// Agent response (if completed).
    pub response: Option<String>,
    /// Tool calls made during this turn.
    pub tool_calls: Vec<TurnToolCall>,
    /// Turn state.
    pub state: TurnState,Thread.state(不是 turn)是另一组状态:
Idle | Processing | AwaitingApproval | Completed | Interrupted——管的是整个 thread 当前能不能接受新 turn。
    /// When the turn started.
    pub started_at: DateTime<Utc>,
    /// When the turn completed.
    pub completed_at: Option<DateTime<Utc>>,
    /// Error message (if failed).
    pub error: Option<String>,
    /// Agent's reasoning narrative for this turn.
    /// Cleaned via `clean_response` and sanitized through `SafetyLayer` before storage.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub narrative: Option<String>,
    /// Transient image content parts for multimodal LLM input.
    /// Not serialized — images are only needed for the current LLM call.
    /// The text description in `user_input` persists for compaction/context.
    #[serde(skip)]
    pub image_content_parts: Vec<ironclaw_llm::ContentPart>,
}

一个 Turn = 一轮完整的人机交互,从用户输入到 agent 给出最终回复。

  turns[0] = Turn {
      user_input: "帮我查东京天气",
      response: Some("东京明天 25°C,晴..."),
      tool_calls: vec![{tool: weather_lookup, args: {city: "tokyo"}, result: Ok(...)}],
      state: TurnState::Complete,
  }

  turns[1] = Turn {
      user_input: "还有大阪",
      response: Some("大阪明天 22°C..."),
      tool_calls: vec![{tool: weather_lookup, args: {city: "osaka"}, result: Ok(...)}],
      state: TurnState::Complete,
  }
/// State of a turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TurnState {
    /// Turn is being processed.
    Processing,
    /// Turn completed successfully.
    Completed,
    /// Turn failed with an error.
    Failed,
    /// Turn was interrupted.
    Interrupted,
}
  和 ContextWindow 的关系

  LLM 每次调用要看完整历史——直接把 turns 整个序列化塞进去。但 turn 数多了会超出 context window,所以:

  [ turns[0..10] ]   ← 早期对话,被 compaction 压缩成 summary 写入 workspace
  [ turns[11..50] ]  ← 中期
  [ turns[51..N] ]   ← 最近 10 个 turn,保留原文

  compaction.rs 的三种策略(MoveToWorkspace / Summarize / Truncate)都在操作这个 turns Vec——把旧的 turn 摘掉、生成 summary。

  turns 是 LLM 上下文的直接来源——没有 turns 就没法继续对话(agent 失忆)。

  ---
  和 UndoManager 的关系

  UndoManager 在 SessionManager 层、不在 Thread 里。它存的 checkpoints 是 turns Vec 的快照(按 CLAUDE.md:"Checkpoints store message lists (max 20 by default)"):

  UndoManager {
      checkpoints: VecDeque<Vec<Turn>>,  // 历史快照栈
  }

  /undo → 弹一个 checkpoint → 把 Thread.turns 整个替换成那个 checkpoint 的 turns。

  turns 是 undo 的最小单位——不是按 turn 撤销,是按"这一批 turn 一起回滚"。

  ---
  一句话总结

  ▎ Thread.turns 是这个对话线程的完整历史,按时间顺序存所有用户输入、agent 回复、调用的工具、turn 状态——既是 LLM 上下文的来源,也是 undo 的最小单位,也是 compaction 处理的目标。

5. redo/undo

①Checkpoint

pub struct Checkpoint {
    /// Unique checkpoint ID.
    pub id: Uuid,
    /// Turn number this checkpoint was created at.
    pub turn_number: usize,
    /// Snapshot of messages at this point.
    pub messages: Vec<ChatMessage>,
    /// Description of what happened at this checkpoint.
    pub description: String,
}

②UndoManager

pub struct UndoManager {
    /// Stack of past checkpoints (for undo).
    undo_stack: VecDeque<Checkpoint>,
    /// Stack of future checkpoints (for redo).
    redo_stack: Vec<Checkpoint>,
    /// Maximum checkpoints to keep.
    max_checkpoints: usize,
}

③链路

我看的是当前主仓库 `ironclaw` 的 **v1 实现**。代码知识图谱当前缺失,所以按仓库说明回退到源码追踪。`ironclaw-study/src/agent/undo.rs` 与当前文件内容一致,下面以 `ironclaw` 为准。

> 这不是文件内容的 undo,而是**会话上下文的 undo/redo**。  
> 它保存的是 `Vec<ChatMessage>` 快照,然后通过这些消息重新构造 `Thread.turns`。

---

# 一、完整调用链

## `/undo` 链路

```text
Channel 收到 "/undo"
  ↓
Agent::run()
  src/agent/agent_loop.rs:1109
  src/agent/agent_loop.rs:1517
  ↓
Agent::handle_message()
  src/agent/agent_loop.rs:1716
  ↓
SubmissionParser::parse("/undo")
  src/agent/submission.rs:14
  src/agent/submission.rs:20-22
  ↓
Submission::Undo
  src/agent/submission.rs:368-369
  ↓
resolve_thread(...)
  src/agent/agent_loop.rs:1917-1925
  ↓
match Submission::Undo
  src/agent/agent_loop.rs:2175
  ↓
Agent::process_undo(session, thread_id)
  src/agent/thread_ops.rs:1575
  ↓
SessionManager::get_undo_manager(thread_id)
  src/agent/session_manager.rs:278-297
  ↓
读取当前 Thread 状态:
  thread.messages()
  thread.turn_number()
  src/agent/thread_ops.rs:1593-1595
  ↓
UndoManager::undo(current_turn, current_messages)
  src/agent/undo.rs:108-127
  ↓
1. 当前状态压入 redo_stack
2. undo_stack.pop_back() 得到目标 Checkpoint
  ↓
Thread::restore_from_messages(checkpoint.messages)
  src/agent/thread_ops.rs:1597-1607
  src/agent/session.rs:613-685
  ↓
返回文本:
"Undone to turn N. X undo(s) remaining."
  ↓
SubmissionResult::Ok
  ↓
HandleOutcome::Respond
  src/agent/agent_loop.rs:2393-2411
  ↓
ChannelManager::respond()
  + StatusUpdate::Status("Done")
  src/agent/agent_loop.rs:654-686
```

## `/redo` 链路

```text
Channel 收到 "/redo"
  ↓
Agent::run()
  ↓
Agent::handle_message()
  ↓
SubmissionParser::parse("/redo")
  src/agent/submission.rs:23-25
  ↓
Submission::Redo
  ↓
src/agent/agent_loop.rs:2176
  ↓
Agent::process_redo()
  src/agent/thread_ops.rs:1613-1643
  ↓
SessionManager::get_undo_manager(thread_id)
  ↓
读取 thread.messages() + thread.turn_number()
  ↓
UndoManager::redo(current_turn, current_messages)
  src/agent/undo.rs:142-160
  ↓
1. 当前状态压回 undo_stack
2. redo_stack.pop() 得到未来状态
  ↓
Thread::restore_from_messages(checkpoint.messages)
  ↓
返回:
"Redone to turn N."
```

---

# 二、Checkpoint 在什么时候创建

Checkpoint 不是执行 `/undo` 时才创建,而是**每个正常用户 turn 开始前**创建。

代码在:

- `src/agent/thread_ops.rs:685-720`:先检查并执行自动 compaction
- `src/agent/thread_ops.rs:722-737`:创建 checkpoint
- `src/agent/thread_ops.rs:739-750`:然后才 `start_turn()`
- `src/agent/thread_ops.rs:753-768`:之后把新用户消息持久化到 DB

关键代码逻辑等价于:

```rust
let undo_mgr = self.session_manager.get_undo_manager(thread_id).await;

let sess = session.lock().await;
let thread = sess.threads.get(&thread_id)?;

undo_mgr.lock().await.checkpoint(
    thread.turn_number(),
    thread.messages(),
    format!("Before turn {}", thread.turn_number()),
);

// checkpoint 完成以后,才真正开始下一轮
thread.start_turn(effective_content);
```

所以 checkpoint 表示:

```text
“处理这一轮用户消息之前,会话是什么样子”
```

而不是:

```text
“这一轮处理完以后,会话是什么样子”
```

例如当前已经完成两轮:

```text
Turn 0: User A → Assistant A
Turn 1: User B → Assistant B
```

当处理 `User B` 之前,已经创建了包含 Turn 0 的 checkpoint。因此执行一次 `/undo`,恢复到的是:

```text
Turn 0: User A → Assistant A
```

整轮 `User B → Assistant B` 被从内存中的 `Thread.turns` 移除。

---

# 三、Checkpoint 具体保存什么

定义在 `src/agent/undo.rs:16-27`:

```rust
pub struct Checkpoint {
    pub id: Uuid,
    pub turn_number: usize,
    pub messages: Vec<ChatMessage>,
    pub description: String,
}
```

分别是:

| 字段          | 用途                         |
| ------------- | ---------------------------- |
| `id`          | 随机生成的 checkpoint UUID   |
| `turn_number` | 创建快照时的显示轮次         |
| `messages`    | 当时的 LLM 消息序列          |
| `description` | 如 `Before turn 2`、`Turn 3` |

构造位置是 `src/agent/undo.rs:29-43`。

特别注意:它**不保存完整 `Thread`**,只保存 `ChatMessage` 序列。

没有保存:

- `Thread.id`
- `Thread.metadata`
- `pending_approval`
- `pending_auth`
- `pending_messages`
- 原始 `Turn.started_at` / `completed_at`
- `user_message_id`
- `Turn.narrative`
- 外部工具副作用
- DB 中已经写入的消息状态

因此它更准确的名字其实是:

```text
conversation message snapshot
```

而不是完整线程快照。

---

# 四、两个栈具体如何工作

结构定义在 `src/agent/undo.rs:45-57`:

```rust
pub struct UndoManager {
    undo_stack: VecDeque<Checkpoint>,
    redo_stack: Vec<Checkpoint>,
    max_checkpoints: usize,
}
```

## 为什么 undo 用 `VecDeque`

`undo_stack` 需要同时做两种操作:

```text
push_back / pop_back:操作最新 checkpoint
pop_front:超过容量时删除最老 checkpoint
```

因此使用 `VecDeque`。

方向如下:

```text
undo_stack:
最老 → [S0, S1, S2] ← 最新
       front         back
```

- 新 checkpoint:`push_back`
- undo:`pop_back`
- 超出容量:`pop_front`

`redo_stack` 只需要普通 LIFO,因此用 `Vec<Checkpoint>`:

```text
redo_stack.push(...)
redo_stack.pop()
```

---

# 五、Undo 的栈变化

核心代码位于 `src/agent/undo.rs:108-127`。

逻辑可以简化成:

```rust
pub fn undo(
    &mut self,
    current_turn: usize,
    current_messages: Vec<ChatMessage>,
) -> Option<Checkpoint> {
    if self.undo_stack.is_empty() {
        return None;
    }

    // 先保存“现在”,以便 redo
    self.redo_stack.push(Checkpoint::new(
        current_turn,
        current_messages,
        format!("Turn {}", current_turn),
    ));

    // 再取出“之前”
    self.undo_stack.pop_back()
}
```

假设:

```text
undo_stack = [S0, S1]
redo_stack = []
当前 Thread = S2
```

执行 `undo()`:

### 第一步:当前状态进入 redo

```text
redo_stack = [S2]
```

### 第二步:弹出最近 checkpoint

```text
返回 S1
undo_stack = [S0]
```

### 第三步:调用者恢复 S1

```text
当前 Thread = S1
```

最终:

```text
undo_stack = [S0]
当前状态    = S1
redo_stack = [S2]
```

---

# 六、连续 Undo 为什么能逐步后退

假设一开始:

```text
undo_stack = [S0, S1, S2]
当前状态    = S3
redo_stack = []
```

第一次 undo:

```text
undo_stack = [S0, S1]
当前状态    = S2
redo_stack = [S3]
```

第二次 undo:

```text
undo_stack = [S0]
当前状态    = S1
redo_stack = [S3, S2]
```

第三次 undo:

```text
undo_stack = []
当前状态    = S0
redo_stack = [S3, S2, S1]
```

这里 redo 栈从左到右是入栈顺序,`pop()` 从右边取,因此首先 redo 到 `S1`,然后 `S2`,最后 `S3`。

相关测试:

- `src/agent/undo.rs:292-317`:连续 undo
- `src/agent/undo.rs:320-348`:undo/redo 循环
- `src/agent/undo.rs:351-375`:栈数量不变量

---

# 七、Redo 的栈变化

核心代码位于 `src/agent/undo.rs:142-160`:

```rust
pub fn redo(
    &mut self,
    current_turn: usize,
    current_messages: Vec<ChatMessage>,
) -> Option<Checkpoint> {
    if self.redo_stack.is_empty() {
        return None;
    }

    // 保存当前状态,让 redo 之后还能再次 undo
    let current = Checkpoint::new(
        current_turn,
        current_messages,
        format!("Turn {}", current_turn),
    );
    self.push_undo(current);

    // 取出最近撤销的未来状态
    self.redo_stack.pop()
}
```

接着前面的状态:

```text
undo_stack = [S0]
当前状态    = S1
redo_stack = [S3, S2]
```

第一次 redo:

1. 当前 `S1` 压回 undo
2. 从 redo 弹出 `S2`
3. 恢复 `S2`

结果:

```text
undo_stack = [S0, S1]
当前状态    = S2
redo_stack = [S3]
```

第二次 redo:

```text
undo_stack = [S0, S1, S2]
当前状态    = S3
redo_stack = []
```

因为 redo 时会把当前状态放回 undo,所以 redo 完以后仍然可以再次 undo。

---

# 八、为什么 `undo_count + redo_count` 基本不变

注释在 `src/agent/undo.rs:45-49`。

一次 undo:

```text
undo_stack -1
redo_stack +1
总数不变
```

一次 redo:

```text
undo_stack +1
redo_stack -1
总数不变
```

例如:

```text
开始:undo=3, redo=0, total=3
undo:undo=2, redo=1, total=3
redo:undo=3, redo=0, total=3
```

只有这些操作会改变总数:

1. `checkpoint()`:增加 checkpoint
2. `checkpoint()` 清空 redo,创建新历史分支
3. `clear()`:全部清空
4. 超过容量时丢弃最老 checkpoint
5. `restore(checkpoint_id)`:删除目标之后的 checkpoints

因此注释中的“总数不变”特指正常 undo/redo 循环。

---

# 九、新操作为什么会清空 Redo

在 `src/agent/undo.rs:84-98`:

```rust
pub fn checkpoint(...) {
    self.redo_stack.clear();

    let checkpoint = Checkpoint::new(...);
    self.push_undo(checkpoint);
}
```

场景:

```text
S0 → S1 → S2
          ↑
        undo
```

恢复到 `S1` 后:

```text
当前 = S1
redo = [S2]
```

如果这时用户不 redo,而是发送了新消息,产生新状态 `S1'`:

```text
S0 → S1 → S1'
```

旧的 `S2` 已经属于另一条历史分支,不能再 redo,所以新 turn 开始前的 `checkpoint()` 会:

```rust
redo_stack.clear();
```

这就是常见编辑器的行为:**undo 后一旦产生新编辑,旧 redo 链失效。**

---

# 十、容量限制

默认最大 checkpoint 数是 20:

```rust
const DEFAULT_MAX_CHECKPOINTS: usize = 20;
```

见 `src/agent/undo.rs:13-14`。

压入 undo 栈时:

```rust
fn push_undo(&mut self, checkpoint: Checkpoint) {
    self.undo_stack.push_back(checkpoint);

    while self.undo_stack.len() > self.max_checkpoints {
        self.undo_stack.pop_front();
    }
}
```

见 `src/agent/undo.rs:76-82`。

例如最大容量为 3:

```text
push S0 → [S0]
push S1 → [S0, S1]
push S2 → [S0, S1, S2]
push S3 → [S1, S2, S3]  // S0 从 front 删除
push S4 → [S2, S3, S4]  // S1 删除
```

测试在 `src/agent/undo.rs:267-275`。

---

# 十一、状态是怎么真正恢复的

`UndoManager` 自己**不修改 Thread**。

它只负责:

```text
管理 checkpoint
返回应该恢复的 Checkpoint
```

真正应用状态的是调用者:

```rust
thread.restore_from_messages(checkpoint.messages);
```

Undo 调用位置:

- `src/agent/thread_ops.rs:1597-1604`

Redo 调用位置:

- `src/agent/thread_ops.rs:1634-1639`

恢复实现位于 `src/agent/session.rs:605-685`。

## 恢复过程

```rust
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
    self.turns.clear();
    self.state = ThreadState::Idle;

    // 遍历 ChatMessage
    // 遇到 User 就创建 Turn
    // 解析 assistant tool_calls
    // 解析 tool_result
    // 解析最终 assistant response
    // 重建 turns
}
```

它能识别以下消息结构:

```text
user
  → assistant_with_tool_calls
  → tool_result
  → assistant final response
```

对应代码:

- 创建 turn:`src/agent/session.rs:620-623`
- 恢复工具调用:`src/agent/session.rs:624-642`
- 恢复工具结果:`src/agent/session.rs:644-662`
- 恢复最终回复:`src/agent/session.rs:668-674`
- 放回 `thread.turns`:`src/agent/session.rs:676-677`

`Thread::messages()` 则负责反向把 `turns` 展平成 `ChatMessage`:

- `src/agent/session.rs:511-591`

所以整体是:

```text
Thread.turns
   ↓ Thread::messages()
Vec<ChatMessage>
   ↓ 保存到 Checkpoint
Checkpoint
   ↓ undo/redo
Vec<ChatMessage>
   ↓ Thread::restore_from_messages()
新的 Thread.turns
```

---

# 十二、UndoManager 为什么是“每线程一个”

存储在 `SessionManager`:

```rust
undo_managers: RwLock<HashMap<Uuid, Arc<Mutex<UndoManager>>>>
```

见 `src/agent/session_manager.rs:27-32`。

key 是 `thread_id`:

```text
thread A UUID → UndoManager A
thread B UUID → UndoManager B
```

因此两个会话线程的撤销历史互不影响。

新建线程时创建:

- `src/agent/session_manager.rs:234-238`

从 DB hydration/register 线程时创建:

- `src/agent/session_manager.rs:243-269`

获取时使用 double-check:

- `src/agent/session_manager.rs:278-297`

```rust
pub async fn get_undo_manager(
    &self,
    thread_id: Uuid,
) -> Arc<Mutex<UndoManager>>
```

相同 `thread_id` 返回相同的 `Arc`。测试见:

- `src/agent/session_manager.rs:449-458`
- `src/agent/session_manager.rs:679-700`

空闲 Session 被 prune 时,对应 UndoManager 也会删除:

- `src/agent/session_manager.rs:383-395`

---

# 十三、它没有做什么——几个很重要的边界

## 1. Undo 只修改内存,不回滚数据库

`process_undo()` 和 `process_redo()` 中没有任何 DB 写入或删除调用:

- `src/agent/thread_ops.rs:1575-1643`

但正常 turn 的用户消息和 assistant 回复会持久化:

- 用户消息:`src/agent/thread_ops.rs:753-768`
- assistant 回复在后续 turn 结束分支持久化,例如 `src/agent/thread_ops.rs:2454-2460`

所以:

```text
内存中的 Thread 被撤销
数据库中的原消息仍然存在
```

这也符合仓库的总原则:LLM 数据不删除。

直接后果是:

- undo 不会删除 DB 消息;
- UndoManager 本身也没有序列化进 DB;
- 进程重启或 Session 被 prune 后,历史线程从 DB hydration;
- hydration 会创建一个新的空 UndoManager;
- 已经 undo 的消息可能重新从 DB 构建进 Thread;
- restart 以后不能继续使用原来的 undo/redo 栈。

DB hydration 在:

- 加载历史消息:`src/agent/thread_ops.rs:452-457`
- `restore_from_messages()`:`src/agent/thread_ops.rs:503-510`
- 注册一个新的 UndoManager:`src/agent/thread_ops.rs:520-527`

所以当前 undo 是**会话内、内存级 undo**,不是 durable undo。

---

## 2. 不会撤销工具产生的外部副作用

即使某一轮调用了:

```text
发送 Telegram 消息
写文件
创建 job
修改外部服务
安装 extension
```

undo 只移除本地 `Thread.turns` 中对应的消息上下文,并不会反向执行这些工具。

即:

```text
撤销对话上下文 ≠ 补偿外部操作
```

这也是为什么它不能视为事务回滚。

---

## 3. UI 没有收到完整历史替换事件

`process_undo()` 只返回:

```text
Undone to turn ...
```

`process_redo()` 只返回:

```text
Redone to turn ...
```

并没有发送 `StatusUpdate::ConversationHistory`。

因此后端内存中的 LLM 上下文已经变了,但当前 UI 不一定会立即删除画面上已展示的旧消息。真正发送给 channel 的是一个普通文本确认和 `Done`:

- 结果转换:`src/agent/agent_loop.rs:2381-2411`
- 发送确认:`src/agent/agent_loop.rs:654-686`

---

## 4. 恢复不是无损还原

`restore_from_messages()` 会重新构建 `Turn`,所以部分信息会变化或丢失:

- `started_at` 重新生成
- `completed_at` 重新生成
- `user_message_id` 变为 `None`
- `narrative` 不恢复
- 图片 `image_content_parts` 不恢复
- 工具 error/result 的实时区别不完整保留
- `pending_approval`、`pending_auth` 不在 checkpoint 中

源码甚至明确说明恢复工具结果时,只需要还原 LLM 看到的内容,不保留 live turn 的 error/success 区别:

- `src/agent/session.rs:653-658`

所以这是“根据消息重放出来的等价上下文”,不是完整对象级快照。

---

## 5. 自动 compaction 发生在 checkpoint 之前

顺序是:

```text
auto compact
  ↓
checkpoint
  ↓
start new turn
```

见:

- compaction:`src/agent/thread_ops.rs:685-720`
- checkpoint:`src/agent/thread_ops.rs:722-737`

因此如果开始新 turn 前触发了自动 compaction,之后 undo 恢复的是:

```text
compaction 之后、新 turn 之前的状态
```

不能通过 undo 回到 compaction 之前。

---

# 十四、`clear()` 和指定 checkpoint 恢复

## 清空历史

`UndoManager::clear()`:

```rust
pub fn clear(&mut self) {
    self.undo_stack.clear();
    self.redo_stack.clear();
}
```

位于 `src/agent/undo.rs:197-201`。

用户执行 `/clear` 时:

1. 清除 `thread.turns`
2. 清除 queued messages
3. 将状态设为 Idle
4. 清除 UndoManager 两个栈

见 `src/agent/thread_ops.rs:1714-1738`。

因此 `/clear` 后既不能 undo,也不能 redo。

## 按 UUID 恢复 checkpoint

还有一个随机访问 API:

```rust
pub fn restore(&mut self, checkpoint_id: Uuid) -> Option<Checkpoint>
```

位于 `src/agent/undo.rs:203-220`。

它会:

1. 只在 `undo_stack` 查目标;
2. 清空 redo;
3. 删除目标之后的 checkpoint;
4. 弹出并返回目标 checkpoint。

调用链:

```text
/resume <uuid>
  ↓
Submission::Resume
  ↓
process_resume()
  src/agent/thread_ops.rs:2959-2981
  ↓
UndoManager::restore(checkpoint_id)
  ↓
Thread::restore_from_messages(...)
```

不过当前 `get_checkpoint()` 和 `list_checkpoints()` 都是 `#[cfg(test)]`,见 `src/agent/undo.rs:182-195`,生产代码里没有通用的 checkpoint 列表 UI。这个 `/resume` checkpoint 流程与“列出历史线程”的 `/resume` UI 语义存在一定脱节。

---

# 十五、用一个完整例子串起来

假设开始时:

```text
Thread.turns = []
undo = []
redo = []
```

## 用户发送 A

在处理 A 之前:

```text
checkpoint(empty)
undo = [S0]
redo = []
```

处理结束:

```text
当前 = S1 = [A, response A]
undo = [S0]
redo = []
```

## 用户发送 B

处理 B 之前:

```text
checkpoint(S1)
undo = [S0, S1]
redo = []
```

处理结束:

```text
当前 = S2 = [A, response A, B, response B]
undo = [S0, S1]
redo = []
```

## 用户执行 `/undo`

```text
把当前 S2 压入 redo
从 undo 弹出 S1
恢复 S1
```

结果:

```text
当前 = S1
undo = [S0]
redo = [S2]
```

## 用户执行 `/redo`

```text
把当前 S1 压回 undo
从 redo 弹出 S2
恢复 S2
```

结果:

```text
当前 = S2
undo = [S0, S1]
redo = []
```

## 再次 `/undo`,然后发送 C

先 undo:

```text
当前 = S1
undo = [S0]
redo = [S2]
```

发送 C 前调用 `checkpoint(S1)`:

```text
redo.clear()
undo.push(S1)
```

结果:

```text
当前新分支 = S1 → C
undo = [S0, S1]
redo = []       // 原来的 S2 永久不能 redo
```

---

# 结论

当前实现的本质是:

```text
每个 turn 开始前保存 ChatMessage 快照
       +
两个 LIFO 历史栈
       +
通过 ChatMessage 重建 Thread.turns
```

最核心的三个方法分别是:

1. 创建历史点:`UndoManager::checkpoint()`  
   `src/agent/undo.rs:87`

2. 在 undo/redo 两个栈之间移动状态:  
   `UndoManager::undo()`:`src/agent/undo.rs:108`  
   `UndoManager::redo()`:`src/agent/undo.rs:142`

3. 将 checkpoint 应用到线程:  
   `Thread::restore_from_messages()`:`src/agent/session.rs:613`

需要特别记住:**它只回滚当前进程里的对话上下文,不回滚 DB、不回滚工具副作用,也不是完整 Thread 快照。**