Skip to main content

ag_session/
model.rs

1//! Shared session identity, lifecycle, settings, and aggregate models.
2
3use std::borrow::Borrow;
4use std::fmt;
5use std::ops::Deref;
6use std::path::Path;
7use std::str::FromStr;
8use std::sync::Arc;
9
10pub use ag_agent::SpeedMode;
11use ag_agent::{AgentSelection, ReasoningLevel};
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
20/// Number of seconds in one activity-calendar day.
21const SECONDS_PER_DAY: i64 = 86_400;
22
23/// Shared stable identifier for one session.
24///
25/// Session identifiers are cloned heavily across maps, events, and worker
26/// tasks. Wrapping the identifier in `Arc<str>` keeps those clones cheap while
27/// retaining borrowed `&str` lookup support.
28#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub struct SessionId(Arc<str>);
30
31impl SessionId {
32    /// Returns the session identifier as a string slice.
33    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/// Role one session plays in a multi-session workflow.
139///
140/// The role is orthogonal to [`SessionStatus`]: an orchestrator moves through
141/// the same lifecycle states as any other session, but its worktree exists only
142/// for repository reads and never receives commits. Diff, merge, and
143/// review-request affordances are therefore gated on the role rather than on a
144/// dedicated lifecycle state.
145#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
146pub enum SessionRole {
147    /// Ordinary session that owns the changes on its own branch.
148    #[default]
149    Worker,
150    /// Worker whose write capabilities are owned exclusively by an
151    /// orchestration coordinator.
152    OrchestrationWorker,
153    /// Temporary read-only researcher whose worktree changes are discarded
154    /// after its report is captured.
155    OrchestrationResearcher,
156    /// Controller session that plans and supervises child worker sessions.
157    Orchestrator,
158}
159
160impl SessionRole {
161    /// Returns whether this role produces commits on its own session branch.
162    ///
163    /// Orchestrators read the repository to plan work but delegate every edit
164    /// to child sessions, so their branch stays empty for the session's whole
165    /// lifetime.
166    pub fn owns_branch_changes(self) -> bool {
167        matches!(self, SessionRole::Worker | SessionRole::OrchestrationWorker)
168    }
169
170    /// Returns whether Agentty should observe worktree changes after a turn.
171    ///
172    /// Research children do not own their changes, but the coordinator records
173    /// whether a child attempted edits before discarding its worktree.
174    pub fn tracks_worktree_changes(self) -> bool {
175        !matches!(self, SessionRole::Orchestrator)
176    }
177
178    /// Returns whether an end user may submit turns or branch mutations
179    /// directly to this session.
180    pub fn accepts_user_turns(self) -> bool {
181        !self.is_managed()
182    }
183
184    /// Returns whether this worker is owned by an orchestration coordinator.
185    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/// High-level lifecycle state for one session.
221#[derive(Clone, Copy, Debug, Eq, PartialEq)]
222pub enum SessionStatus {
223    /// Session has been created but has not started its first agent turn yet.
224    Draft,
225    /// An agent turn or its post-processing workflow is running.
226    InProgress,
227    /// The session is ready for user review and follow-up work.
228    Review,
229    /// The session is generating focused-review output.
230    AgentReview,
231    /// The session is waiting for model clarification responses.
232    Question,
233    /// The session is waiting in the merge queue.
234    Queued,
235    /// The session branch is being rebased.
236    Rebasing,
237    /// The session branch is being merged.
238    Merging,
239    /// A remote merge awaits local target-branch synchronization.
240    Merged,
241    /// The session completed successfully.
242    Done,
243    /// The session was canceled before completion.
244    Canceled,
245}
246
247impl SessionStatus {
248    /// Ordered list of all session statuses.
249    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    /// Returns whether this status permits opening the chat composer.
264    pub fn allows_chat_composer(self) -> bool {
265        self.allows_session_actions()
266            || matches!(self, SessionStatus::InProgress | SessionStatus::Rebasing)
267    }
268
269    /// Returns whether this status permits idle session actions.
270    pub fn allows_session_actions(self) -> bool {
271        self.allows_review_actions()
272            || matches!(self, SessionStatus::Draft | SessionStatus::Question)
273    }
274
275    /// Returns whether this status permits opening the session diff.
276    pub fn allows_diff_view(self) -> bool {
277        self.allows_review_actions() || self.is_read_only()
278    }
279
280    /// Returns whether this status permits starting or queueing session sync.
281    pub fn allows_rebase_action(self) -> bool {
282        self.allows_review_actions() || self == SessionStatus::InProgress
283    }
284
285    /// Returns whether this status keeps review actions enabled.
286    pub fn allows_review_actions(self) -> bool {
287        matches!(self, SessionStatus::Review | SessionStatus::AgentReview)
288    }
289
290    /// Returns whether this status can seed a follow-on continuation session.
291    pub fn allows_terminal_continuation(self) -> bool {
292        matches!(self, SessionStatus::Done | SessionStatus::Canceled)
293    }
294
295    /// Returns whether this lifecycle state permits only local inspection.
296    pub fn is_read_only(self) -> bool {
297        matches!(self, SessionStatus::Merged)
298    }
299
300    /// Returns whether this status is stable enough to start a stacked child.
301    pub fn allows_stacked_child_start(self) -> bool {
302        self.allows_review_actions()
303    }
304
305    /// Returns whether this status represents branch-mutating stack work.
306    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    /// Returns whether a transition to `next` is valid.
319    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/// Complete session-scoped execution settings returned by the API.
408#[derive(Clone, Debug, Eq, PartialEq)]
409pub struct SessionSettings {
410    /// Agent provider and model selected for this session.
411    pub agent: AgentSelection,
412    /// Base branch used to create the session.
413    pub base_branch: String,
414    /// Whether the session uses deferred draft materialization.
415    pub is_draft: bool,
416    /// Parent session for a one-level stacked session.
417    pub parent_session_id: Option<SessionId>,
418    /// Workspace personality selected for future turns, when present.
419    pub personality_id: Option<String>,
420    /// Owning project identifier.
421    pub project_id: i64,
422    /// Session-scoped reasoning level.
423    pub reasoning_level: ReasoningLevel,
424    /// Role this session plays in a multi-session workflow.
425    pub role: SessionRole,
426    /// Session-scoped response-speed preference.
427    pub speed_mode: SpeedMode,
428}
429
430/// Persisted forge linkage for one session.
431#[derive(Clone, Debug, Eq, PartialEq)]
432pub struct ReviewRequest {
433    /// Unix timestamp of the most recent successful refresh.
434    pub last_refreshed_at: i64,
435    /// Normalized remote summary captured at `last_refreshed_at`.
436    pub summary: ReviewRequestSummary,
437}
438
439/// Complete persisted session aggregate returned by the programmatic API.
440#[derive(Clone, Debug, Eq, PartialEq)]
441pub struct Session {
442    /// Session creation timestamp in Unix seconds.
443    pub created_at: i64,
444    /// Staged draft prompt that has not entered the durable transcript.
445    pub draft_prompt: Option<String>,
446    /// Stable session identifier.
447    pub id: SessionId,
448    /// Ordered durable transcript messages.
449    pub messages: Vec<SessionMessage>,
450    /// Published upstream branch reference, when present.
451    pub published_upstream_ref: Option<String>,
452    /// Structured clarification questions waiting for answers.
453    pub questions: Vec<QuestionItem>,
454    /// Chat messages waiting behind the active turn or queued branch action.
455    pub queued_messages: Vec<String>,
456    /// Persisted review-request linkage, when present.
457    pub review_request: Option<ReviewRequest>,
458    /// Complete session-scoped execution settings.
459    pub settings: SessionSettings,
460    /// Current lifecycle status.
461    pub status: SessionStatus,
462    /// Latest persisted structured summary.
463    pub summary: Option<String>,
464    /// Optional user-visible session title.
465    pub title: Option<String>,
466    /// Last update timestamp in Unix seconds.
467    pub updated_at: i64,
468}
469
470/// Converts Unix timestamp seconds to a day key after applying a UTC offset.
471pub fn activity_day_key_with_offset(timestamp_seconds: i64, utc_offset_seconds: i64) -> i64 {
472    timestamp_seconds
473        .saturating_add(utc_offset_seconds)
474        .div_euclid(SECONDS_PER_DAY)
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn session_id_round_trips_json() {
483        // Arrange
484        let session_id = SessionId::from("session-1");
485
486        // Act
487        let serialized = serde_json::to_string(&session_id).expect("id should serialize");
488        let deserialized =
489            serde_json::from_str::<SessionId>(&serialized).expect("id should deserialize");
490
491        // Assert
492        assert_eq!(deserialized, session_id);
493        assert_eq!(deserialized.as_str(), "session-1");
494        assert_eq!(AsRef::<Path>::as_ref(&deserialized), Path::new("session-1"));
495    }
496
497    #[test]
498    fn session_id_supports_owned_conversions_and_comparisons() {
499        // Arrange
500        let source = Arc::<str>::from("session-1");
501        let expected = "session-1".to_string();
502
503        // Act
504        let session_id = SessionId::from(source);
505        let owned = String::from(session_id.clone());
506
507        // Assert
508        assert_eq!(session_id, expected);
509        assert_eq!(session_id, &expected);
510        assert_eq!(owned, expected);
511    }
512
513    #[test]
514    fn session_status_round_trips_persisted_values() {
515        // Arrange
516        let statuses = SessionStatus::ALL;
517
518        // Act
519        let round_tripped = statuses.map(|status| {
520            status
521                .to_string()
522                .parse::<SessionStatus>()
523                .expect("status should parse")
524        });
525
526        // Assert
527        assert_eq!(round_tripped, statuses);
528        assert_eq!(
529            "Committing"
530                .parse::<SessionStatus>()
531                .expect("legacy status should parse"),
532            SessionStatus::InProgress
533        );
534        assert!("Unknown".parse::<SessionStatus>().is_err());
535    }
536
537    #[test]
538    fn session_role_round_trips_persisted_values() {
539        // Arrange
540        let roles = [
541            SessionRole::Worker,
542            SessionRole::OrchestrationWorker,
543            SessionRole::OrchestrationResearcher,
544            SessionRole::Orchestrator,
545        ];
546
547        // Act
548        let round_tripped = roles.map(|role| {
549            role.to_string()
550                .parse::<SessionRole>()
551                .expect("role should parse")
552        });
553
554        // Assert
555        assert_eq!(round_tripped, roles);
556        assert_eq!(SessionRole::default(), SessionRole::Worker);
557        assert!("Unknown".parse::<SessionRole>().is_err());
558    }
559
560    #[test]
561    fn branch_ownership_and_tracking_follow_session_role() {
562        // Arrange / Act / Assert
563        assert!(SessionRole::Worker.owns_branch_changes());
564        assert!(SessionRole::OrchestrationWorker.owns_branch_changes());
565        assert!(!SessionRole::OrchestrationResearcher.owns_branch_changes());
566        assert!(!SessionRole::Orchestrator.owns_branch_changes());
567        assert!(SessionRole::Worker.tracks_worktree_changes());
568        assert!(SessionRole::OrchestrationResearcher.tracks_worktree_changes());
569        assert!(!SessionRole::Orchestrator.tracks_worktree_changes());
570        assert!(SessionRole::Worker.accepts_user_turns());
571        assert!(!SessionRole::OrchestrationWorker.accepts_user_turns());
572        assert!(!SessionRole::OrchestrationResearcher.accepts_user_turns());
573        assert!(SessionRole::OrchestrationWorker.is_managed());
574        assert!(SessionRole::OrchestrationResearcher.is_managed());
575    }
576
577    #[test]
578    fn merged_status_only_transitions_to_done() {
579        // Arrange
580        let status = SessionStatus::Merged;
581
582        // Act / Assert
583        assert!(status.can_transition_to(SessionStatus::Merged));
584        assert!(status.can_transition_to(SessionStatus::Done));
585        assert!(!status.can_transition_to(SessionStatus::Review));
586    }
587
588    #[test]
589    fn terminal_continuation_is_available_for_done_and_canceled_sessions() {
590        // Arrange
591        let statuses = SessionStatus::ALL;
592
593        // Act
594        let continuation_statuses = statuses
595            .into_iter()
596            .filter(|status| status.allows_terminal_continuation())
597            .collect::<Vec<_>>();
598
599        // Assert
600        assert_eq!(
601            continuation_statuses,
602            vec![SessionStatus::Done, SessionStatus::Canceled]
603        );
604    }
605
606    #[test]
607    fn activity_day_key_applies_offsets() {
608        // Arrange / Act / Assert
609        assert_eq!(activity_day_key_with_offset(86_399, 1), 1);
610        assert_eq!(activity_day_key_with_offset(0, -1), -1);
611    }
612}