Skip to main content

gate4agent_types/
resume.rs

1use crate::{
2    normalize_semantic_prompt, validate_candidate_id, HistoryValidationError, OperationId,
3    ProviderSessionIdentity, ProviderSessionKey, TerminalSize, WORKING_DIRECTORY_MAX_BYTES,
4};
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8pub const RESUME_ERROR_MAX_BYTES: usize = 4_096;
9
10#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
11pub struct ResumeLaunchRequest {
12    pub working_directory: String,
13    pub terminal_size: TerminalSize,
14    #[serde(default)]
15    pub initial_prompt: Option<String>,
16}
17
18impl ResumeLaunchRequest {
19    pub fn validate(&self) -> Result<(), ResumeValidationError> {
20        if self.working_directory.trim().is_empty()
21            || self.working_directory.len() > WORKING_DIRECTORY_MAX_BYTES
22            || self.working_directory.chars().any(char::is_control)
23        {
24            return Err(ResumeValidationError::InvalidWorkingDirectory);
25        }
26        if !self.terminal_size.is_valid() {
27            return Err(ResumeValidationError::InvalidTerminalSize);
28        }
29        if let Some(prompt) = &self.initial_prompt {
30            normalize_semantic_prompt(prompt)
31                .map_err(|error| ResumeValidationError::InvalidInitialPrompt(error.to_string()))?;
32        }
33        Ok(())
34    }
35}
36
37#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
38#[serde(tag = "kind", rename_all = "kebab-case")]
39pub enum ResumeTarget {
40    CurrentProvider,
41    ProviderSession { identity: ProviderSessionIdentity },
42    HistoryCandidate { candidate_id: String },
43}
44
45impl ResumeTarget {
46    pub fn validate(&self) -> Result<(), ResumeValidationError> {
47        match self {
48            Self::CurrentProvider => Ok(()),
49            Self::ProviderSession { identity } => identity
50                .validate()
51                .map_err(|error| ResumeValidationError::InvalidProviderSession(error.to_string())),
52            Self::HistoryCandidate { candidate_id } => validate_candidate_id(candidate_id)
53                .map_err(ResumeValidationError::InvalidHistoryCandidate),
54        }
55    }
56}
57
58#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
59#[serde(tag = "kind", rename_all = "kebab-case")]
60pub enum ResumeAuthorityTarget {
61    ProviderSession { identity: ProviderSessionIdentity },
62    HistoryCandidate { candidate_id: String },
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "kebab-case")]
67pub enum ResumePhase {
68    Authorizing,
69    Spawning,
70}
71
72#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
73pub struct PendingResumeOperation {
74    pub operation_id: OperationId,
75    pub target: ResumeTarget,
76    pub request: ResumeLaunchRequest,
77    pub phase: ResumePhase,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
81pub struct ResumeSessionSummary {
82    pub key: ProviderSessionKey,
83    pub id: String,
84}
85
86impl From<&ProviderSessionIdentity> for ResumeSessionSummary {
87    fn from(identity: &ProviderSessionIdentity) -> Self {
88        Self {
89            key: identity.key,
90            id: identity.id.clone(),
91        }
92    }
93}
94
95#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
96pub struct ResumeSnapshot {
97    pub pending: Option<PendingResumeOperation>,
98    pub last_session: Option<ResumeSessionSummary>,
99    pub last_error: Option<String>,
100}
101
102#[derive(Clone, Debug, Error, Eq, PartialEq)]
103pub enum ResumeValidationError {
104    #[error("resume working directory is empty, too large, or contains controls")]
105    InvalidWorkingDirectory,
106    #[error("resume terminal size is outside the supported bounded range")]
107    InvalidTerminalSize,
108    #[error("resume initial prompt is invalid: {0}")]
109    InvalidInitialPrompt(String),
110    #[error("resume history candidate is invalid: {0}")]
111    InvalidHistoryCandidate(HistoryValidationError),
112    #[error("resume provider session is invalid: {0}")]
113    InvalidProviderSession(String),
114    #[error("resume error is empty, too large, or contains unsafe controls")]
115    InvalidError,
116}
117
118pub fn validate_resume_error(message: &str) -> Result<(), ResumeValidationError> {
119    if message.trim().is_empty()
120        || message.len() > RESUME_ERROR_MAX_BYTES
121        || message
122            .chars()
123            .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
124    {
125        return Err(ResumeValidationError::InvalidError);
126    }
127    Ok(())
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn resume_requests_and_public_summaries_are_bounded() {
136        assert_eq!(
137            ResumeLaunchRequest {
138                working_directory: "C:/repo".to_owned(),
139                terminal_size: TerminalSize {
140                    rows: 24,
141                    columns: 80,
142                },
143                initial_prompt: Some("continue\u{0000} safely".to_owned()),
144            }
145            .validate(),
146            Ok(())
147        );
148        assert!(ResumeLaunchRequest {
149            working_directory: "C:/repo".to_owned(),
150            terminal_size: TerminalSize {
151                rows: 24,
152                columns: 80,
153            },
154            initial_prompt: Some("x".repeat(crate::SEMANTIC_PROMPT_MAX_BYTES + 1)),
155        }
156        .validate()
157        .is_err());
158        assert!(ResumeTarget::HistoryCandidate {
159            candidate_id: r"C:\sessions\one.jsonl".to_owned(),
160        }
161        .validate()
162        .is_err());
163        assert!(ResumeTarget::ProviderSession {
164            identity: ProviderSessionIdentity {
165                key: ProviderSessionKey::SessionId,
166                id: "-unsafe-option".to_owned(),
167                transcript_path: None,
168            },
169        }
170        .validate()
171        .is_err());
172        assert!(validate_resume_error("authorization failed").is_ok());
173        assert!(validate_resume_error("bad\0error").is_err());
174    }
175}