1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::backend_kind::BackendKind;
5use crate::ipc::{CliUsageLimitState, DisplayMode, ScreenStatus};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct AgentAttention {
10 pub kind: String,
11 pub reason: String,
12 pub at: DateTime<Utc>,
13}
14
15#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17#[derive(Default)]
18pub enum SessionScope {
19 #[default]
20 Thread,
21 Chat,
22}
23
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "snake_case")]
26pub enum ChatMode {
27 Group,
28 Topic,
29 P2p,
30}
31
32impl From<&str> for ChatMode {
33 fn from(value: &str) -> Self {
34 match value {
35 "p2p" | "P2P" => ChatMode::P2p,
36 "topic" | "TOPIC" => ChatMode::Topic,
37 _ => ChatMode::Group,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "snake_case")]
44#[derive(Default)]
45pub enum SessionStatus {
46 #[default]
47 Active,
48 Closed,
49}
50
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum PendingResponseCardState {
54 Open,
55 Patched,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
59pub struct AdoptedFrom {
60 #[serde(default)]
61 pub backend_kind: BackendKind,
62 #[serde(default)]
63 pub tmux_target: Option<String>,
64 #[serde(default)]
65 pub zellij_session: Option<String>,
66 #[serde(default)]
67 pub zellij_pane_id: Option<String>,
68 #[serde(default)]
69 pub herdr_workspace_id: Option<String>,
70 #[serde(default)]
71 pub herdr_pane_id: Option<String>,
72 pub original_cli_pid: i32,
73 #[serde(default)]
74 pub session_id: Option<String>,
75 #[serde(default)]
76 pub cli_id: Option<String>,
77 pub cwd: String,
78 #[serde(default)]
79 pub pane_cols: Option<u16>,
80 #[serde(default)]
81 pub pane_rows: Option<u16>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct Session {
86 pub session_id: String,
87 pub title: String,
88 pub chat_id: String,
89 pub root_message_id: String,
90 #[serde(default)]
91 pub chat_type: Option<String>,
92 #[serde(default)]
93 pub quote_target_id: Option<String>,
94 #[serde(default)]
95 pub scope: SessionScope,
96 #[serde(default)]
97 pub status: SessionStatus,
98 pub created_at: DateTime<Utc>,
99 #[serde(default)]
100 pub closed_at: Option<DateTime<Utc>>,
101 #[serde(default)]
102 pub working_dir: Option<String>,
103 pub lark_app_id: String,
104 #[serde(default)]
105 pub owner_open_id: Option<String>,
106 #[serde(default)]
111 pub quote_target_sender_open_id: Option<String>,
112 #[serde(default)]
113 pub worker_pid: Option<u32>,
114 #[serde(default)]
115 pub cli_id: Option<String>,
116 #[serde(default)]
117 pub cli_bin: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub cgroup_slice: Option<String>,
120 #[serde(default)]
121 pub cli_args: Vec<String>,
122 #[serde(default)]
123 pub cli_session_id: Option<String>,
124 #[serde(default)]
125 pub last_cli_input: Option<String>,
126 #[serde(default)]
127 pub stream_card_id: Option<String>,
128 #[serde(default)]
129 pub stream_card_nonce: Option<String>,
130 #[serde(default)]
131 pub display_mode: Option<DisplayMode>,
132 #[serde(default)]
133 pub current_screen: Option<String>,
134 #[serde(default)]
135 pub last_screen_status: Option<ScreenStatus>,
136 #[serde(default)]
137 pub usage_limit: Option<CliUsageLimitState>,
138 #[serde(default)]
139 pub current_image_key: Option<String>,
140 #[serde(default)]
141 pub tui_prompt_card_id: Option<String>,
142 #[serde(default)]
143 pub tui_prompt_options: Vec<crate::ipc::TuiPromptOption>,
144 #[serde(default)]
145 pub tui_prompt_multi_select: Option<bool>,
146 #[serde(default)]
147 pub tui_toggled_indices: Vec<usize>,
148 #[serde(default)]
149 pub pending_response_card_id: Option<String>,
150 #[serde(default)]
151 pub pending_response_card_state: Option<PendingResponseCardState>,
152 #[serde(default)]
153 pub last_patched_response_card_id: Option<String>,
154 #[serde(default)]
155 pub terminal_url: Option<String>,
156 #[serde(default)]
157 pub last_final_output_turn_id: Option<String>,
158 #[serde(default)]
159 pub last_final_output: Option<String>,
160 #[serde(default)]
167 pub last_explicit_send_at: Option<DateTime<Utc>>,
168 #[serde(default)]
169 pub adopted_from: Option<AdoptedFrom>,
170 #[serde(default)]
171 pub model: Option<String>,
172 #[serde(default)]
173 pub locale: Option<String>,
174 #[serde(default)]
175 pub bot_name: Option<String>,
176 #[serde(default)]
177 pub bot_open_id: Option<String>,
178 #[serde(default)]
179 pub resume_session_id: Option<String>,
180 #[serde(default)]
181 pub disable_cli_bypass: bool,
182 #[serde(default)]
186 pub backend_kind: BackendKind,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub herdr_session: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub herdr_workspace_id: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub herdr_pane_id: Option<String>,
196 #[serde(default)]
197 pub initial_prompt: Option<String>,
198 #[serde(default)]
205 pub thread_id: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub agent_attention: Option<AgentAttention>,
210 #[serde(default)]
215 pub current_turn_id: Option<String>,
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn session_deser_old_data_without_quote_target_sender_open_id() {
224 let json = r#"{
227 "session_id": "test-sess-1",
228 "title": "test",
229 "chat_id": "chat-1",
230 "root_message_id": "root-1",
231 "scope": "thread",
232 "status": "active",
233 "created_at": "2025-01-01T00:00:00Z",
234 "lark_app_id": "app-1",
235 "owner_open_id": "ou_owner"
236 }"#;
237 let session: Session = serde_json::from_str(json).expect("should deserialize old session");
238 assert_eq!(session.session_id, "test-sess-1");
239 assert_eq!(session.owner_open_id.as_deref(), Some("ou_owner"));
240 assert_eq!(
241 session.quote_target_sender_open_id, None,
242 "old sessions without the field should default to None"
243 );
244 }
245
246 #[test]
247 fn session_deser_with_quote_target_sender_open_id() {
248 let json = r#"{
249 "session_id": "test-sess-2",
250 "title": "test",
251 "chat_id": "chat-1",
252 "root_message_id": "root-1",
253 "scope": "thread",
254 "status": "active",
255 "created_at": "2025-01-01T00:00:00Z",
256 "lark_app_id": "app-1",
257 "owner_open_id": "ou_owner",
258 "quote_target_sender_open_id": "ou_sender"
259 }"#;
260 let session: Session = serde_json::from_str(json).expect("should deserialize session");
261 assert_eq!(session.owner_open_id.as_deref(), Some("ou_owner"));
262 assert_eq!(
263 session.quote_target_sender_open_id.as_deref(),
264 Some("ou_sender"),
265 "new sessions should preserve the quote target sender"
266 );
267 }
268
269 #[test]
270 fn session_deser_old_data_without_agent_attention() {
271 let json = r#"{
274 "session_id": "test-sess-3",
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 }"#;
284 let session: Session = serde_json::from_str(json).expect("should deserialize old session");
285 assert_eq!(session.session_id, "test-sess-3");
286 assert_eq!(
287 session.agent_attention, None,
288 "old sessions without the field should default to None"
289 );
290 }
291
292 #[test]
293 fn session_deser_with_agent_attention() {
294 let json = r#"{
295 "session_id": "test-sess-4",
296 "title": "test",
297 "chat_id": "chat-1",
298 "root_message_id": "root-1",
299 "scope": "thread",
300 "status": "active",
301 "created_at": "2025-01-01T00:00:00Z",
302 "lark_app_id": "app-1",
303 "owner_open_id": "ou_owner",
304 "agent_attention": {
305 "kind": "blocked",
306 "reason": "need approval",
307 "at": "2025-06-01T12:00:00Z"
308 }
309 }"#;
310 let session: Session = serde_json::from_str(json).expect("should deserialize session");
311 let aa = session
312 .agent_attention
313 .as_ref()
314 .expect("should have agent_attention");
315 assert_eq!(aa.kind, "blocked");
316 assert_eq!(aa.reason, "need approval");
317 assert_eq!(aa.at.to_rfc3339(), "2025-06-01T12:00:00+00:00");
318 }
319
320 #[test]
321 fn session_deser_old_data_without_current_turn_id() {
322 let json = r#"{
325 "session_id": "test-sess-5",
326 "title": "test",
327 "chat_id": "chat-1",
328 "root_message_id": "root-1",
329 "scope": "thread",
330 "status": "active",
331 "created_at": "2025-01-01T00:00:00Z",
332 "lark_app_id": "app-1"
333 }"#;
334 let session: Session = serde_json::from_str(json).expect("should deserialize old session");
335 assert_eq!(
336 session.current_turn_id, None,
337 "old sessions without the field should default to None"
338 );
339 }
340
341 #[test]
342 fn session_deser_with_current_turn_id() {
343 let json = r#"{
344 "session_id": "test-sess-6",
345 "title": "test",
346 "chat_id": "chat-1",
347 "root_message_id": "root-1",
348 "scope": "thread",
349 "status": "active",
350 "created_at": "2025-01-01T00:00:00Z",
351 "lark_app_id": "app-1",
352 "current_turn_id": "turn-abc"
353 }"#;
354 let session: Session = serde_json::from_str(json).expect("should deserialize session");
355 assert_eq!(
356 session.current_turn_id.as_deref(),
357 Some("turn-abc"),
358 "new sessions should preserve current_turn_id"
359 );
360 }
361}