Skip to main content

gate4agent_adapters/
resume.rs

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
14/// Builds argv only for live control-plane resume contracts grounded in pinned
15/// reference implementations or verified vendor CLI contracts.
16///
17/// `None` is a supported negative capability.
18pub 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}