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}