1use gate4agent_types::{
2 AdapterId, ProviderSessionIdentity, ProviderSessionKey, PROVIDER_SESSION_LOCATOR_MAX_BYTES,
3};
4use thiserror::Error;
5
6pub const RESUME_SESSION_ID_MAX_BYTES: usize = 512;
7
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct ResumePlan {
10 pub program: String,
11 pub args: Vec<String>,
12}
13
14pub fn build_resume_plan(
19 adapter_id: &AdapterId,
20 session_id: &str,
21) -> Result<Option<ResumePlan>, ResumeAdapterError> {
22 build_resume_plan_for_identity(
23 adapter_id,
24 &ProviderSessionIdentity {
25 key: ProviderSessionKey::SessionId,
26 id: session_id.to_owned(),
27 transcript_path: None,
28 },
29 )
30}
31
32pub fn build_resume_plan_for_identity(
33 adapter_id: &AdapterId,
34 identity: &ProviderSessionIdentity,
35) -> Result<Option<ResumePlan>, ResumeAdapterError> {
36 let Some((program, prefix, expected_key, use_transcript_path)) = (match adapter_id.as_str() {
37 "claude-code" => Some((
38 "claude",
39 &["--resume"][..],
40 ProviderSessionKey::SessionId,
41 false,
42 )),
43 "codex" => Some((
44 "codex",
45 &["resume"][..],
46 ProviderSessionKey::SessionId,
47 false,
48 )),
49 "kimi" => Some((
50 "kimi",
51 &["--session"][..],
52 ProviderSessionKey::SessionId,
53 false,
54 )),
55 "pi" => Some((
56 "pi",
57 &["--session"][..],
58 ProviderSessionKey::SessionId,
59 true,
60 )),
61 "mimo-code" => Some((
62 "mimo",
63 &["--session"][..],
64 ProviderSessionKey::SessionId,
65 false,
66 )),
67 "grok" => Some((
68 "grok",
69 &["--resume"][..],
70 ProviderSessionKey::SessionId,
71 false,
72 )),
73 "cursor" | "omp" | "amp" => None,
74 id => return Err(ResumeAdapterError::UnsupportedAdapter(id.to_owned())),
75 }) else {
76 return Ok(None);
77 };
78
79 if identity.key != expected_key {
80 return Err(ResumeAdapterError::InvalidSessionKey);
81 }
82
83 let session_id = normalize_session_id(&identity.id)?;
84 let target = if use_transcript_path {
85 normalize_transcript_path(
86 identity
87 .transcript_path
88 .as_deref()
89 .ok_or(ResumeAdapterError::MissingTranscriptPath)?,
90 )?
91 } else {
92 session_id
93 };
94 let mut args = prefix
95 .iter()
96 .map(|value| (*value).to_owned())
97 .collect::<Vec<_>>();
98 args.push(target);
99 Ok(Some(ResumePlan {
100 program: program.to_owned(),
101 args,
102 }))
103}
104
105fn normalize_transcript_path(value: &str) -> Result<String, ResumeAdapterError> {
106 let value = value.trim();
107 if value.is_empty()
108 || value.len() > PROVIDER_SESSION_LOCATOR_MAX_BYTES
109 || value.starts_with('-')
110 || value
111 .chars()
112 .any(|character| character.is_control() || character == '\u{7f}')
113 {
114 return Err(ResumeAdapterError::InvalidTranscriptPath);
115 }
116 Ok(value.to_owned())
117}
118
119fn normalize_session_id(value: &str) -> Result<String, ResumeAdapterError> {
120 let value = value.trim();
121 if value.is_empty()
122 || value.len() > RESUME_SESSION_ID_MAX_BYTES
123 || value.starts_with('-')
124 || value
125 .chars()
126 .any(|character| character.is_control() || character == '\u{7f}')
127 {
128 return Err(ResumeAdapterError::InvalidSessionId);
129 }
130 Ok(value.to_owned())
131}
132
133#[derive(Clone, Debug, Error, Eq, PartialEq)]
134pub enum ResumeAdapterError {
135 #[error("resume session ID is empty, unsafe, or too large")]
136 InvalidSessionId,
137 #[error("resume provider session key does not match the adapter contract")]
138 InvalidSessionKey,
139 #[error("resume adapter requires an authoritative transcript path")]
140 MissingTranscriptPath,
141 #[error("resume transcript path is empty, unsafe, or too large")]
142 InvalidTranscriptPath,
143 #[error("resume adapter is unavailable for {0}")]
144 UnsupportedAdapter(String),
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn id(value: &str) -> AdapterId {
152 AdapterId::new(value).unwrap()
153 }
154
155 #[test]
156 fn grounded_resume_argv_is_exact() {
157 let cases = [
158 ("claude-code", "claude", vec!["--resume", "s1"]),
159 ("codex", "codex", vec!["resume", "s1"]),
160 ("kimi", "kimi", vec!["--session", "s1"]),
161 ("mimo-code", "mimo", vec!["--session", "s1"]),
162 ("grok", "grok", vec!["--resume", "s1"]),
163 ];
164 for (adapter, program, args) in cases {
165 let plan = build_resume_plan(&id(adapter), "s1").unwrap().unwrap();
166 assert_eq!(plan.program, program);
167 assert_eq!(plan.args, args);
168 }
169 }
170
171 #[test]
172 fn pi_resume_requires_the_paired_authoritative_session_file() {
173 let identity = ProviderSessionIdentity {
174 key: ProviderSessionKey::SessionId,
175 id: "pi-session-1".to_owned(),
176 transcript_path: Some("C:/sessions/pi-session-1.jsonl".to_owned()),
177 };
178 let plan = build_resume_plan_for_identity(&id("pi"), &identity)
179 .unwrap()
180 .unwrap();
181 assert_eq!(plan.program, "pi");
182 assert_eq!(plan.args, ["--session", "C:/sessions/pi-session-1.jsonl"]);
183 assert_eq!(
184 build_resume_plan(&id("pi"), "pi-session-1"),
185 Err(ResumeAdapterError::MissingTranscriptPath)
186 );
187 }
188
189 #[test]
190 fn provider_session_key_must_match_the_resume_contract() {
191 let identity = ProviderSessionIdentity {
192 key: ProviderSessionKey::ConversationId,
193 id: "conversation-1".to_owned(),
194 transcript_path: None,
195 };
196 assert_eq!(
197 build_resume_plan_for_identity(&id("claude-code"), &identity),
198 Err(ResumeAdapterError::InvalidSessionKey)
199 );
200 }
201
202 #[test]
203 fn transcript_locator_is_used_only_by_pi_and_never_as_an_option() {
204 let claude = ProviderSessionIdentity {
205 key: ProviderSessionKey::SessionId,
206 id: "claude-session-1".to_owned(),
207 transcript_path: Some("C:/sessions/claude-rollout-1.jsonl".to_owned()),
208 };
209 let plan = build_resume_plan_for_identity(&id("claude-code"), &claude)
210 .unwrap()
211 .unwrap();
212 assert_eq!(plan.args, ["--resume", "claude-session-1"]);
213
214 for path in ["", " --help", "bad\npath"] {
215 let pi = ProviderSessionIdentity {
216 key: ProviderSessionKey::SessionId,
217 id: "pi-session-1".to_owned(),
218 transcript_path: Some(path.to_owned()),
219 };
220 assert_eq!(
221 build_resume_plan_for_identity(&id("pi"), &pi),
222 Err(ResumeAdapterError::InvalidTranscriptPath)
223 );
224 }
225 }
226
227 #[test]
228 fn kimi_live_resume_uses_the_v0_31_session_flag() {
229 let plan = build_resume_plan(&id("kimi"), "session_1")
230 .unwrap()
231 .unwrap();
232 assert_eq!(plan.program, "kimi");
233 assert_eq!(plan.args, ["--session", "session_1"]);
234 }
235
236 #[test]
237 fn unsafe_session_ids_never_reach_argv() {
238 for value in ["", " --help", "line\nbreak"] {
239 assert_eq!(
240 build_resume_plan(&id("grok"), value),
241 Err(ResumeAdapterError::InvalidSessionId)
242 );
243 }
244 }
245
246 #[test]
247 fn unsupported_and_explicit_negative_capabilities_are_distinct() {
248 assert_eq!(build_resume_plan(&id("cursor"), "s1").unwrap(), None);
249 assert!(matches!(
250 build_resume_plan(&id("future-provider"), "s1"),
251 Err(ResumeAdapterError::UnsupportedAdapter(_))
252 ));
253 }
254}