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}
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 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 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}