Skip to main content

beam_core/
session.rs

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