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