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}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn session_deser_old_data_without_quote_target_sender_open_id() {
203        // Old session JSON (before quote_target_sender_open_id was added)
204        // must deserialize with the field defaulting to None.
205        let json = r#"{
206            "session_id": "test-sess-1",
207            "title": "test",
208            "chat_id": "chat-1",
209            "root_message_id": "root-1",
210            "scope": "thread",
211            "status": "active",
212            "created_at": "2025-01-01T00:00:00Z",
213            "lark_app_id": "app-1",
214            "owner_open_id": "ou_owner"
215        }"#;
216        let session: Session = serde_json::from_str(json).expect("should deserialize old session");
217        assert_eq!(session.session_id, "test-sess-1");
218        assert_eq!(session.owner_open_id.as_deref(), Some("ou_owner"));
219        assert_eq!(
220            session.quote_target_sender_open_id, None,
221            "old sessions without the field should default to None"
222        );
223    }
224
225    #[test]
226    fn session_deser_with_quote_target_sender_open_id() {
227        let json = r#"{
228            "session_id": "test-sess-2",
229            "title": "test",
230            "chat_id": "chat-1",
231            "root_message_id": "root-1",
232            "scope": "thread",
233            "status": "active",
234            "created_at": "2025-01-01T00:00:00Z",
235            "lark_app_id": "app-1",
236            "owner_open_id": "ou_owner",
237            "quote_target_sender_open_id": "ou_sender"
238        }"#;
239        let session: Session = serde_json::from_str(json).expect("should deserialize session");
240        assert_eq!(session.owner_open_id.as_deref(), Some("ou_owner"));
241        assert_eq!(
242            session.quote_target_sender_open_id.as_deref(),
243            Some("ou_sender"),
244            "new sessions should preserve the quote target sender"
245        );
246    }
247
248    #[test]
249    fn session_deser_old_data_without_agent_attention() {
250        // Old session JSON (before agent_attention was added)
251        // must deserialize with the field defaulting to None.
252        let json = r#"{
253            "session_id": "test-sess-3",
254            "title": "test",
255            "chat_id": "chat-1",
256            "root_message_id": "root-1",
257            "scope": "thread",
258            "status": "active",
259            "created_at": "2025-01-01T00:00:00Z",
260            "lark_app_id": "app-1",
261            "owner_open_id": "ou_owner"
262        }"#;
263        let session: Session = serde_json::from_str(json).expect("should deserialize old session");
264        assert_eq!(session.session_id, "test-sess-3");
265        assert_eq!(
266            session.agent_attention, None,
267            "old sessions without the field should default to None"
268        );
269    }
270
271    #[test]
272    fn session_deser_with_agent_attention() {
273        let json = r#"{
274            "session_id": "test-sess-4",
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            "agent_attention": {
284                "kind": "blocked",
285                "reason": "need approval",
286                "at": "2025-06-01T12:00:00Z"
287            }
288        }"#;
289        let session: Session = serde_json::from_str(json).expect("should deserialize session");
290        let aa = session
291            .agent_attention
292            .as_ref()
293            .expect("should have agent_attention");
294        assert_eq!(aa.kind, "blocked");
295        assert_eq!(aa.reason, "need approval");
296        assert_eq!(aa.at.to_rfc3339(), "2025-06-01T12:00:00+00:00");
297    }
298}