Skip to main content

beam_core/
session.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::backend_kind::BackendKind;
5use crate::ipc::{CliUsageLimitState, DisplayMode, ScreenStatus};
6
7/// Agent attention state set via `--attention` flag, analogous to botmux `agentAttention`.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct AgentAttention {
10    pub kind: String,
11    pub reason: String,
12    pub at: DateTime<Utc>,
13}
14
15#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17#[derive(Default)]
18pub enum SessionScope {
19    #[default]
20    Thread,
21    Chat,
22}
23
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "snake_case")]
26pub enum ChatMode {
27    Group,
28    Topic,
29    P2p,
30}
31
32impl From<&str> for ChatMode {
33    fn from(value: &str) -> Self {
34        match value {
35            "p2p" | "P2P" => ChatMode::P2p,
36            "topic" | "TOPIC" => ChatMode::Topic,
37            _ => ChatMode::Group,
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "snake_case")]
44#[derive(Default)]
45pub enum SessionStatus {
46    #[default]
47    Active,
48    Closed,
49}
50
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum PendingResponseCardState {
54    Open,
55    Patched,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
59pub struct AdoptedFrom {
60    #[serde(default)]
61    pub backend_kind: BackendKind,
62    #[serde(default)]
63    pub tmux_target: Option<String>,
64    #[serde(default)]
65    pub zellij_session: Option<String>,
66    #[serde(default)]
67    pub zellij_pane_id: Option<String>,
68    #[serde(default)]
69    pub herdr_workspace_id: Option<String>,
70    #[serde(default)]
71    pub herdr_pane_id: Option<String>,
72    pub original_cli_pid: i32,
73    #[serde(default)]
74    pub session_id: Option<String>,
75    #[serde(default)]
76    pub cli_id: Option<String>,
77    pub cwd: String,
78    #[serde(default)]
79    pub pane_cols: Option<u16>,
80    #[serde(default)]
81    pub pane_rows: Option<u16>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct Session {
86    pub session_id: String,
87    pub title: String,
88    pub chat_id: String,
89    pub root_message_id: String,
90    #[serde(default)]
91    pub chat_type: Option<String>,
92    #[serde(default)]
93    pub quote_target_id: Option<String>,
94    #[serde(default)]
95    pub scope: SessionScope,
96    #[serde(default)]
97    pub status: SessionStatus,
98    pub created_at: DateTime<Utc>,
99    #[serde(default)]
100    pub closed_at: Option<DateTime<Utc>>,
101    #[serde(default)]
102    pub working_dir: Option<String>,
103    pub lark_app_id: String,
104    #[serde(default)]
105    pub owner_open_id: Option<String>,
106    /// Sender open_id of the trigger/quote message for the current turn.
107    /// Aligns with botmux `quoteTargetSenderOpenId`.
108    /// May differ from `owner_open_id` in multi-user group chats where
109    /// a non-owner triggers a follow-up turn.
110    #[serde(default)]
111    pub quote_target_sender_open_id: Option<String>,
112    #[serde(default)]
113    pub worker_pid: Option<u32>,
114    #[serde(default)]
115    pub cli_id: Option<String>,
116    #[serde(default)]
117    pub cli_bin: Option<String>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub cgroup_slice: Option<String>,
120    #[serde(default)]
121    pub cli_args: Vec<String>,
122    #[serde(default)]
123    pub cli_session_id: Option<String>,
124    #[serde(default)]
125    pub last_cli_input: Option<String>,
126    #[serde(default)]
127    pub stream_card_id: Option<String>,
128    #[serde(default)]
129    pub stream_card_nonce: Option<String>,
130    #[serde(default)]
131    pub display_mode: Option<DisplayMode>,
132    #[serde(default)]
133    pub current_screen: Option<String>,
134    #[serde(default)]
135    pub last_screen_status: Option<ScreenStatus>,
136    #[serde(default)]
137    pub usage_limit: Option<CliUsageLimitState>,
138    #[serde(default)]
139    pub current_image_key: Option<String>,
140    #[serde(default)]
141    pub tui_prompt_card_id: Option<String>,
142    #[serde(default)]
143    pub tui_prompt_options: Vec<crate::ipc::TuiPromptOption>,
144    #[serde(default)]
145    pub tui_prompt_multi_select: Option<bool>,
146    #[serde(default)]
147    pub tui_toggled_indices: Vec<usize>,
148    #[serde(default)]
149    pub pending_response_card_id: Option<String>,
150    #[serde(default)]
151    pub pending_response_card_state: Option<PendingResponseCardState>,
152    #[serde(default)]
153    pub last_patched_response_card_id: Option<String>,
154    #[serde(default)]
155    pub terminal_url: Option<String>,
156    #[serde(default)]
157    pub last_final_output_turn_id: Option<String>,
158    #[serde(default)]
159    pub last_final_output: Option<String>,
160    /// Timestamp of the most recent explicit `beam send` (structured final output).
161    /// Set by `handle_final_output_request`; NOT set by worker bridge delivery.
162    /// Used by `should_skip_worker_final_output` to suppress duplicate worker
163    /// output when the model already sent the same content via explicit send.
164    /// Minimal botmux-equivalent: botmux records turn-sends markers; Beam only
165    /// needs a single timestamp for the 10-minute dedupe window.
166    #[serde(default)]
167    pub last_explicit_send_at: Option<DateTime<Utc>>,
168    #[serde(default)]
169    pub adopted_from: Option<AdoptedFrom>,
170    #[serde(default)]
171    pub model: Option<String>,
172    #[serde(default)]
173    pub locale: Option<String>,
174    #[serde(default)]
175    pub bot_name: Option<String>,
176    #[serde(default)]
177    pub bot_open_id: Option<String>,
178    #[serde(default)]
179    pub resume_session_id: Option<String>,
180    #[serde(default)]
181    pub disable_cli_bypass: bool,
182    /// Terminal backend for this session, persisted at create/adopt time.
183    /// Restore only reads the persisted value; a later config flip must not
184    /// move an existing session to another mux.
185    #[serde(default)]
186    pub backend_kind: BackendKind,
187    /// Named Herdr session escape hatch (round-trips with `InitConfig`).
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub herdr_session: Option<String>,
190    /// Herdr public ids. Unlike zellij, these cannot be derived from the
191    /// beam session id, so they are persisted when Ready arrives.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub herdr_workspace_id: Option<String>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub herdr_pane_id: Option<String>,
196    #[serde(default)]
197    pub initial_prompt: Option<String>,
198    /// Feishu thread_id (omt_*), stable topic identifier.
199    /// Present for topic-group messages and p2p thread follow-ups that carry
200    /// thread metadata.  Used as the session-matching anchor for Thread-scoped
201    /// sessions.  For p2p, thread_id may be backfilled from a follow-up message
202    /// after the initial session is created (first p2p session starts with
203    /// thread_id=None and matches follow-ups via root_message_id).
204    #[serde(default)]
205    pub thread_id: Option<String>,
206    /// Agent attention state set via `--attention` flag.
207    /// Cleared on next user inbound message.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub agent_attention: Option<AgentAttention>,
210    /// The turn_id of the most recent input sent to this session.
211    /// Set atomically by send_input before dispatching to the worker.
212    /// Used by the daemon to validate screenshot uploads (CAS check).
213    /// New/restart sessions with no input remain None.
214    #[serde(default)]
215    pub current_turn_id: Option<String>,
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn session_deser_old_data_without_quote_target_sender_open_id() {
224        // Old session JSON (before quote_target_sender_open_id was added)
225        // must deserialize with the field defaulting to None.
226        let json = r#"{
227            "session_id": "test-sess-1",
228            "title": "test",
229            "chat_id": "chat-1",
230            "root_message_id": "root-1",
231            "scope": "thread",
232            "status": "active",
233            "created_at": "2025-01-01T00:00:00Z",
234            "lark_app_id": "app-1",
235            "owner_open_id": "ou_owner"
236        }"#;
237        let session: Session = serde_json::from_str(json).expect("should deserialize old session");
238        assert_eq!(session.session_id, "test-sess-1");
239        assert_eq!(session.owner_open_id.as_deref(), Some("ou_owner"));
240        assert_eq!(
241            session.quote_target_sender_open_id, None,
242            "old sessions without the field should default to None"
243        );
244    }
245
246    #[test]
247    fn session_deser_with_quote_target_sender_open_id() {
248        let json = r#"{
249            "session_id": "test-sess-2",
250            "title": "test",
251            "chat_id": "chat-1",
252            "root_message_id": "root-1",
253            "scope": "thread",
254            "status": "active",
255            "created_at": "2025-01-01T00:00:00Z",
256            "lark_app_id": "app-1",
257            "owner_open_id": "ou_owner",
258            "quote_target_sender_open_id": "ou_sender"
259        }"#;
260        let session: Session = serde_json::from_str(json).expect("should deserialize session");
261        assert_eq!(session.owner_open_id.as_deref(), Some("ou_owner"));
262        assert_eq!(
263            session.quote_target_sender_open_id.as_deref(),
264            Some("ou_sender"),
265            "new sessions should preserve the quote target sender"
266        );
267    }
268
269    #[test]
270    fn session_deser_old_data_without_agent_attention() {
271        // Old session JSON (before agent_attention was added)
272        // must deserialize with the field defaulting to None.
273        let json = r#"{
274            "session_id": "test-sess-3",
275            "title": "test",
276            "chat_id": "chat-1",
277            "root_message_id": "root-1",
278            "scope": "thread",
279            "status": "active",
280            "created_at": "2025-01-01T00:00:00Z",
281            "lark_app_id": "app-1",
282            "owner_open_id": "ou_owner"
283        }"#;
284        let session: Session = serde_json::from_str(json).expect("should deserialize old session");
285        assert_eq!(session.session_id, "test-sess-3");
286        assert_eq!(
287            session.agent_attention, None,
288            "old sessions without the field should default to None"
289        );
290    }
291
292    #[test]
293    fn session_deser_with_agent_attention() {
294        let json = r#"{
295            "session_id": "test-sess-4",
296            "title": "test",
297            "chat_id": "chat-1",
298            "root_message_id": "root-1",
299            "scope": "thread",
300            "status": "active",
301            "created_at": "2025-01-01T00:00:00Z",
302            "lark_app_id": "app-1",
303            "owner_open_id": "ou_owner",
304            "agent_attention": {
305                "kind": "blocked",
306                "reason": "need approval",
307                "at": "2025-06-01T12:00:00Z"
308            }
309        }"#;
310        let session: Session = serde_json::from_str(json).expect("should deserialize session");
311        let aa = session
312            .agent_attention
313            .as_ref()
314            .expect("should have agent_attention");
315        assert_eq!(aa.kind, "blocked");
316        assert_eq!(aa.reason, "need approval");
317        assert_eq!(aa.at.to_rfc3339(), "2025-06-01T12:00:00+00:00");
318    }
319
320    #[test]
321    fn session_deser_old_data_without_current_turn_id() {
322        // Old session JSON (before current_turn_id was added)
323        // must deserialize with the field defaulting to None.
324        let json = r#"{
325            "session_id": "test-sess-5",
326            "title": "test",
327            "chat_id": "chat-1",
328            "root_message_id": "root-1",
329            "scope": "thread",
330            "status": "active",
331            "created_at": "2025-01-01T00:00:00Z",
332            "lark_app_id": "app-1"
333        }"#;
334        let session: Session = serde_json::from_str(json).expect("should deserialize old session");
335        assert_eq!(
336            session.current_turn_id, None,
337            "old sessions without the field should default to None"
338        );
339    }
340
341    #[test]
342    fn session_deser_with_current_turn_id() {
343        let json = r#"{
344            "session_id": "test-sess-6",
345            "title": "test",
346            "chat_id": "chat-1",
347            "root_message_id": "root-1",
348            "scope": "thread",
349            "status": "active",
350            "created_at": "2025-01-01T00:00:00Z",
351            "lark_app_id": "app-1",
352            "current_turn_id": "turn-abc"
353        }"#;
354        let session: Session = serde_json::from_str(json).expect("should deserialize session");
355        assert_eq!(
356            session.current_turn_id.as_deref(),
357            Some("turn-abc"),
358            "new sessions should preserve current_turn_id"
359        );
360    }
361}