1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::ipc::{CliUsageLimitState, DisplayMode, ScreenStatus};
5
6#[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 #[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 #[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 #[serde(default)]
190 pub thread_id: Option<String>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub agent_attention: Option<AgentAttention>,
195 #[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 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 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 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}