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")]
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 #[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 #[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 #[serde(default)]
182 pub thread_id: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub agent_attention: Option<AgentAttention>,
187 #[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 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 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 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}