1use std::borrow::Borrow;
4use std::fmt;
5use std::ops::Deref;
6use std::path::Path;
7use std::str::FromStr;
8use std::sync::Arc;
9
10use ag_agent::{AgentSelection, ReasoningLevel};
11pub use ag_agent::{PermissionMode, SpeedMode};
12pub use ag_forge::{ForgeKind, ReviewRequestState, ReviewRequestSummary};
13use ag_protocol::QuestionItem;
14use serde::de::{self, Deserializer};
15use serde::ser::Serializer;
16use serde::{Deserialize, Serialize};
17
18use crate::SessionMessage;
19
20const SECONDS_PER_DAY: i64 = 86_400;
22
23#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub struct SessionId(Arc<str>);
30
31impl SessionId {
32 pub fn as_str(&self) -> &str {
34 self.0.as_ref()
35 }
36}
37
38impl AsRef<str> for SessionId {
39 fn as_ref(&self) -> &str {
40 self.as_str()
41 }
42}
43
44impl AsRef<Path> for SessionId {
45 fn as_ref(&self) -> &Path {
46 Path::new(self.as_str())
47 }
48}
49
50impl Borrow<str> for SessionId {
51 fn borrow(&self) -> &str {
52 self.as_str()
53 }
54}
55
56impl Deref for SessionId {
57 type Target = str;
58
59 fn deref(&self) -> &Self::Target {
60 self.as_str()
61 }
62}
63
64impl fmt::Display for SessionId {
65 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66 formatter.write_str(self.as_str())
67 }
68}
69
70impl From<&str> for SessionId {
71 fn from(value: &str) -> Self {
72 Self(Arc::<str>::from(value))
73 }
74}
75
76impl From<String> for SessionId {
77 fn from(value: String) -> Self {
78 Self(Arc::<str>::from(value))
79 }
80}
81
82impl From<Arc<str>> for SessionId {
83 fn from(value: Arc<str>) -> Self {
84 Self(value)
85 }
86}
87
88impl From<SessionId> for String {
89 fn from(value: SessionId) -> Self {
90 value.as_str().to_string()
91 }
92}
93
94impl PartialEq<str> for SessionId {
95 fn eq(&self, other: &str) -> bool {
96 self.as_str() == other
97 }
98}
99
100impl PartialEq<&str> for SessionId {
101 fn eq(&self, other: &&str) -> bool {
102 self.as_str() == *other
103 }
104}
105
106impl PartialEq<String> for SessionId {
107 fn eq(&self, other: &String) -> bool {
108 self.as_str() == other
109 }
110}
111
112impl PartialEq<&String> for SessionId {
113 fn eq(&self, other: &&String) -> bool {
114 self.as_str() == other.as_str()
115 }
116}
117
118impl Serialize for SessionId {
119 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
120 where
121 S: Serializer,
122 {
123 serializer.serialize_str(self.as_str())
124 }
125}
126
127impl<'de> Deserialize<'de> for SessionId {
128 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129 where
130 D: Deserializer<'de>,
131 {
132 String::deserialize(deserializer)
133 .map(Self::from)
134 .map_err(de::Error::custom)
135 }
136}
137
138#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
146pub enum SessionRole {
147 #[default]
149 Worker,
150 OrchestrationWorker,
153 OrchestrationResearcher,
156 Orchestrator,
158}
159
160impl SessionRole {
161 pub fn owns_branch_changes(self) -> bool {
167 matches!(self, SessionRole::Worker | SessionRole::OrchestrationWorker)
168 }
169
170 pub fn tracks_worktree_changes(self) -> bool {
175 !matches!(self, SessionRole::Orchestrator)
176 }
177
178 pub fn accepts_user_turns(self) -> bool {
181 !self.is_managed()
182 }
183
184 pub fn is_managed(self) -> bool {
186 matches!(
187 self,
188 SessionRole::OrchestrationWorker | SessionRole::OrchestrationResearcher
189 )
190 }
191}
192
193impl fmt::Display for SessionRole {
194 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
195 let value = match self {
196 SessionRole::Worker => "Worker",
197 SessionRole::OrchestrationWorker => "OrchestrationWorker",
198 SessionRole::OrchestrationResearcher => "OrchestrationResearcher",
199 SessionRole::Orchestrator => "Orchestrator",
200 };
201
202 formatter.write_str(value)
203 }
204}
205
206impl FromStr for SessionRole {
207 type Err = String;
208
209 fn from_str(value: &str) -> Result<Self, Self::Err> {
210 match value {
211 "Worker" => Ok(SessionRole::Worker),
212 "OrchestrationWorker" => Ok(SessionRole::OrchestrationWorker),
213 "OrchestrationResearcher" => Ok(SessionRole::OrchestrationResearcher),
214 "Orchestrator" => Ok(SessionRole::Orchestrator),
215 _ => Err(format!("Unknown role: {value}")),
216 }
217 }
218}
219
220#[derive(Clone, Copy, Debug, Eq, PartialEq)]
222pub enum SessionStatus {
223 Draft,
225 InProgress,
227 Review,
229 AgentReview,
231 Question,
233 Queued,
235 Rebasing,
237 Merging,
239 Merged,
241 Done,
243 Canceled,
245}
246
247impl SessionStatus {
248 pub const ALL: [SessionStatus; 11] = [
250 SessionStatus::Draft,
251 SessionStatus::InProgress,
252 SessionStatus::Review,
253 SessionStatus::AgentReview,
254 SessionStatus::Question,
255 SessionStatus::Queued,
256 SessionStatus::Rebasing,
257 SessionStatus::Merging,
258 SessionStatus::Merged,
259 SessionStatus::Done,
260 SessionStatus::Canceled,
261 ];
262
263 pub fn allows_chat_composer(self) -> bool {
265 self.allows_session_actions()
266 || matches!(self, SessionStatus::InProgress | SessionStatus::Rebasing)
267 }
268
269 pub fn allows_session_actions(self) -> bool {
271 self.allows_review_actions()
272 || matches!(self, SessionStatus::Draft | SessionStatus::Question)
273 }
274
275 pub fn allows_diff_view(self) -> bool {
277 self.allows_review_actions() || self.is_read_only()
278 }
279
280 pub fn allows_rebase_action(self) -> bool {
282 self.allows_review_actions() || self == SessionStatus::InProgress
283 }
284
285 pub fn allows_review_actions(self) -> bool {
287 matches!(self, SessionStatus::Review | SessionStatus::AgentReview)
288 }
289
290 pub fn allows_terminal_continuation(self) -> bool {
292 matches!(self, SessionStatus::Done | SessionStatus::Canceled)
293 }
294
295 pub fn is_read_only(self) -> bool {
297 matches!(self, SessionStatus::Merged)
298 }
299
300 pub fn allows_stacked_child_start(self) -> bool {
302 self.allows_review_actions()
303 }
304
305 pub fn is_stack_branch_mutating(self) -> bool {
307 matches!(
308 self,
309 SessionStatus::InProgress
310 | SessionStatus::Question
311 | SessionStatus::Queued
312 | SessionStatus::Rebasing
313 | SessionStatus::Merging
314 | SessionStatus::Merged
315 )
316 }
317
318 pub fn can_transition_to(self, next: SessionStatus) -> bool {
320 if self == next {
321 return true;
322 }
323 if self == SessionStatus::Merged {
324 return next == SessionStatus::Done;
325 }
326
327 matches!(
328 (self, next),
329 (
330 SessionStatus::Draft,
331 SessionStatus::InProgress | SessionStatus::Canceled
332 ) | (
333 SessionStatus::Draft | SessionStatus::InProgress,
334 SessionStatus::Rebasing
335 ) | (SessionStatus::InProgress, SessionStatus::Canceled)
336 | (SessionStatus::Review, SessionStatus::AgentReview)
337 | (SessionStatus::AgentReview, SessionStatus::Review)
338 | (
339 SessionStatus::Review | SessionStatus::AgentReview,
340 SessionStatus::Merged | SessionStatus::Done
341 )
342 | (
343 SessionStatus::Review | SessionStatus::AgentReview | SessionStatus::Question,
344 SessionStatus::InProgress
345 | SessionStatus::Queued
346 | SessionStatus::Rebasing
347 | SessionStatus::Merging
348 | SessionStatus::Canceled
349 )
350 | (
351 SessionStatus::Queued,
352 SessionStatus::Merging | SessionStatus::Review | SessionStatus::AgentReview
353 )
354 | (
355 SessionStatus::InProgress | SessionStatus::Rebasing,
356 SessionStatus::Review | SessionStatus::AgentReview | SessionStatus::Question
357 )
358 | (
359 SessionStatus::Merging,
360 SessionStatus::Done | SessionStatus::Review | SessionStatus::AgentReview
361 )
362 )
363 }
364}
365
366impl fmt::Display for SessionStatus {
367 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
368 let value = match self {
369 SessionStatus::Draft => "Draft",
370 SessionStatus::InProgress => "InProgress",
371 SessionStatus::Review => "Review",
372 SessionStatus::AgentReview => "AgentReview",
373 SessionStatus::Question => "Question",
374 SessionStatus::Queued => "Queued",
375 SessionStatus::Rebasing => "Rebasing",
376 SessionStatus::Merging => "Merging",
377 SessionStatus::Merged => "Merged",
378 SessionStatus::Done => "Done",
379 SessionStatus::Canceled => "Canceled",
380 };
381
382 formatter.write_str(value)
383 }
384}
385
386impl FromStr for SessionStatus {
387 type Err = String;
388
389 fn from_str(value: &str) -> Result<Self, Self::Err> {
390 match value {
391 "Draft" => Ok(SessionStatus::Draft),
392 "InProgress" | "Committing" => Ok(SessionStatus::InProgress),
393 "Review" => Ok(SessionStatus::Review),
394 "AgentReview" => Ok(SessionStatus::AgentReview),
395 "Question" => Ok(SessionStatus::Question),
396 "Queued" => Ok(SessionStatus::Queued),
397 "Rebasing" => Ok(SessionStatus::Rebasing),
398 "Merging" => Ok(SessionStatus::Merging),
399 "Merged" => Ok(SessionStatus::Merged),
400 "Done" => Ok(SessionStatus::Done),
401 "Canceled" => Ok(SessionStatus::Canceled),
402 _ => Err(format!("Unknown status: {value}")),
403 }
404 }
405}
406
407#[derive(Clone, Debug, Eq, PartialEq)]
409pub struct SessionSettings {
410 pub agent: AgentSelection,
412 pub base_branch: String,
414 pub is_draft: bool,
416 pub parent_session_id: Option<SessionId>,
418 pub permission_mode: PermissionMode,
420 pub personality_id: Option<String>,
422 pub project_id: i64,
424 pub reasoning_level: ReasoningLevel,
426 pub role: SessionRole,
428 pub speed_mode: SpeedMode,
430}
431
432#[derive(Clone, Debug, Eq, PartialEq)]
434pub struct ReviewRequest {
435 pub last_refreshed_at: i64,
437 pub summary: ReviewRequestSummary,
439}
440
441#[derive(Clone, Debug, Eq, PartialEq)]
443pub struct Session {
444 pub created_at: i64,
446 pub draft_prompt: Option<String>,
448 pub id: SessionId,
450 pub messages: Vec<SessionMessage>,
452 pub published_upstream_ref: Option<String>,
454 pub questions: Vec<QuestionItem>,
456 pub queued_messages: Vec<String>,
458 pub review_request: Option<ReviewRequest>,
460 pub settings: SessionSettings,
462 pub status: SessionStatus,
464 pub summary: Option<String>,
466 pub title: Option<String>,
468 pub updated_at: i64,
470}
471
472pub fn activity_day_key_with_offset(timestamp_seconds: i64, utc_offset_seconds: i64) -> i64 {
474 timestamp_seconds
475 .saturating_add(utc_offset_seconds)
476 .div_euclid(SECONDS_PER_DAY)
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn session_id_round_trips_json() {
485 let session_id = SessionId::from("session-1");
487
488 let serialized = serde_json::to_string(&session_id).expect("id should serialize");
490 let deserialized =
491 serde_json::from_str::<SessionId>(&serialized).expect("id should deserialize");
492
493 assert_eq!(deserialized, session_id);
495 assert_eq!(deserialized.as_str(), "session-1");
496 assert_eq!(AsRef::<Path>::as_ref(&deserialized), Path::new("session-1"));
497 }
498
499 #[test]
500 fn session_id_supports_owned_conversions_and_comparisons() {
501 let source = Arc::<str>::from("session-1");
503 let expected = "session-1".to_string();
504
505 let session_id = SessionId::from(source);
507 let owned = String::from(session_id.clone());
508
509 assert_eq!(session_id, expected);
511 assert_eq!(session_id, &expected);
512 assert_eq!(owned, expected);
513 }
514
515 #[test]
516 fn session_status_round_trips_persisted_values() {
517 let statuses = SessionStatus::ALL;
519
520 let round_tripped = statuses.map(|status| {
522 status
523 .to_string()
524 .parse::<SessionStatus>()
525 .expect("status should parse")
526 });
527
528 assert_eq!(round_tripped, statuses);
530 assert_eq!(
531 "Committing"
532 .parse::<SessionStatus>()
533 .expect("legacy status should parse"),
534 SessionStatus::InProgress
535 );
536 assert!("Unknown".parse::<SessionStatus>().is_err());
537 }
538
539 #[test]
540 fn session_role_round_trips_persisted_values() {
541 let roles = [
543 SessionRole::Worker,
544 SessionRole::OrchestrationWorker,
545 SessionRole::OrchestrationResearcher,
546 SessionRole::Orchestrator,
547 ];
548
549 let round_tripped = roles.map(|role| {
551 role.to_string()
552 .parse::<SessionRole>()
553 .expect("role should parse")
554 });
555
556 assert_eq!(round_tripped, roles);
558 assert_eq!(SessionRole::default(), SessionRole::Worker);
559 assert!("Unknown".parse::<SessionRole>().is_err());
560 }
561
562 #[test]
563 fn branch_ownership_and_tracking_follow_session_role() {
564 assert!(SessionRole::Worker.owns_branch_changes());
566 assert!(SessionRole::OrchestrationWorker.owns_branch_changes());
567 assert!(!SessionRole::OrchestrationResearcher.owns_branch_changes());
568 assert!(!SessionRole::Orchestrator.owns_branch_changes());
569 assert!(SessionRole::Worker.tracks_worktree_changes());
570 assert!(SessionRole::OrchestrationResearcher.tracks_worktree_changes());
571 assert!(!SessionRole::Orchestrator.tracks_worktree_changes());
572 assert!(SessionRole::Worker.accepts_user_turns());
573 assert!(!SessionRole::OrchestrationWorker.accepts_user_turns());
574 assert!(!SessionRole::OrchestrationResearcher.accepts_user_turns());
575 assert!(SessionRole::OrchestrationWorker.is_managed());
576 assert!(SessionRole::OrchestrationResearcher.is_managed());
577 }
578
579 #[test]
580 fn merged_status_only_transitions_to_done() {
581 let status = SessionStatus::Merged;
583
584 assert!(status.can_transition_to(SessionStatus::Merged));
586 assert!(status.can_transition_to(SessionStatus::Done));
587 assert!(!status.can_transition_to(SessionStatus::Review));
588 }
589
590 #[test]
591 fn terminal_continuation_is_available_for_done_and_canceled_sessions() {
592 let statuses = SessionStatus::ALL;
594
595 let continuation_statuses = statuses
597 .into_iter()
598 .filter(|status| status.allows_terminal_continuation())
599 .collect::<Vec<_>>();
600
601 assert_eq!(
603 continuation_statuses,
604 vec![SessionStatus::Done, SessionStatus::Canceled]
605 );
606 }
607
608 #[test]
609 fn activity_day_key_applies_offsets() {
610 assert_eq!(activity_day_key_with_offset(86_399, 1), 1);
612 assert_eq!(activity_day_key_with_offset(0, -1), -1);
613 }
614}