1use gate4agent_types::{ProviderSessionIdentity, ProviderSessionKey};
2
3pub const KIMI_PTY_SESSION_ID_MAX_BYTES: usize = 256;
4const KIMI_PTY_IDENTITY_LINE_MAX_BYTES: usize = 1_024;
5const CODEX_PTY_SESSION_ID_BYTES: usize = 36;
6
7#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
8enum AnsiState {
9 #[default]
10 Ground,
11 Escape,
12 Csi,
13 Osc,
14 OscEscape,
15 ControlString,
16 ControlStringEscape,
17}
18
19#[derive(Debug, Default)]
22pub struct KimiPtySessionIdentityExtractor {
23 ansi: AnsiState,
24 line: String,
25 overflowed: bool,
26 observed: Option<String>,
27}
28
29impl KimiPtySessionIdentityExtractor {
30 pub fn push(&mut self, chunk: &str) -> Option<ProviderSessionIdentity> {
31 if self.observed.is_some() {
32 return None;
33 }
34 for character in chunk.chars() {
35 if let Some(id) = self.push_character(character) {
36 self.observed = Some(id.clone());
37 return Some(ProviderSessionIdentity {
38 key: ProviderSessionKey::SessionId,
39 id,
40 transcript_path: None,
41 });
42 }
43 }
44 None
45 }
46
47 pub fn reset_stream(&mut self) {
50 self.ansi = AnsiState::Ground;
51 self.line.clear();
52 self.overflowed = false;
53 }
54
55 pub fn observe_screen(&mut self, screen: &str) -> Option<ProviderSessionIdentity> {
59 if self.observed.is_some() {
60 return None;
61 }
62 let id = screen
63 .lines()
64 .find_map(|line| explicit_kimi_session_id(line, true, true))?;
65 self.observed = Some(id.clone());
66 Some(ProviderSessionIdentity {
67 key: ProviderSessionKey::SessionId,
68 id,
69 transcript_path: None,
70 })
71 }
72
73 fn push_character(&mut self, character: char) -> Option<String> {
74 match self.ansi {
75 AnsiState::Ground => match character {
76 '\u{1b}' => self.ansi = AnsiState::Escape,
77 '\r' | '\n' => {
78 let identity = (!self.overflowed)
79 .then(|| explicit_kimi_session_id(&self.line, true, false))
80 .flatten();
81 self.line.clear();
82 self.overflowed = false;
83 return identity;
84 }
85 '\t' if !self.overflowed => {
86 self.line.push('\t');
87 return explicit_kimi_session_id(&self.line, false, false);
88 }
89 character if character.is_control() => {
90 self.line.clear();
91 self.overflowed = true;
92 }
93 character if !self.overflowed => {
94 if self.line.len().saturating_add(character.len_utf8())
95 > KIMI_PTY_IDENTITY_LINE_MAX_BYTES
96 {
97 self.line.clear();
98 self.overflowed = true;
99 } else {
100 self.line.push(character);
101 return explicit_kimi_session_id(&self.line, false, false);
102 }
103 }
104 _ => {}
105 },
106 AnsiState::Escape => {
107 self.ansi = match character {
108 '[' => AnsiState::Csi,
109 ']' => AnsiState::Osc,
110 'P' | '_' | '^' | 'X' => AnsiState::ControlString,
111 _ => AnsiState::Ground,
112 };
113 }
114 AnsiState::Csi => {
115 if ('@'..='~').contains(&character) {
116 self.ansi = AnsiState::Ground;
117 }
118 }
119 AnsiState::Osc => match character {
120 '\u{7}' => self.ansi = AnsiState::Ground,
121 '\u{1b}' => self.ansi = AnsiState::OscEscape,
122 _ => {}
123 },
124 AnsiState::OscEscape => {
125 self.ansi = if character == '\\' {
126 AnsiState::Ground
127 } else {
128 AnsiState::Osc
129 };
130 }
131 AnsiState::ControlString => {
132 if character == '\u{1b}' {
133 self.ansi = AnsiState::ControlStringEscape;
134 }
135 }
136 AnsiState::ControlStringEscape => {
137 self.ansi = if character == '\\' {
138 AnsiState::Ground
139 } else {
140 AnsiState::ControlString
141 };
142 }
143 }
144 None
145 }
146}
147
148#[derive(Debug, Default)]
152pub struct CodexPtySessionIdentityExtractor {
153 observed: Option<String>,
154}
155
156impl CodexPtySessionIdentityExtractor {
157 pub fn observe_screen(&mut self, screen: &str) -> Option<ProviderSessionIdentity> {
158 if self.observed.is_some() {
159 return None;
160 }
161 let id = screen.lines().find_map(explicit_codex_status_session_id)?;
162 self.observed = Some(id.clone());
163 Some(ProviderSessionIdentity {
164 key: ProviderSessionKey::SessionId,
165 id,
166 transcript_path: None,
167 })
168 }
169}
170
171fn explicit_codex_status_session_id(line: &str) -> Option<String> {
172 let line = line.trim_start_matches([' ', '\t']);
173 let border = line.chars().next()?;
174 if !matches!(border, '│' | '┃' | '║') {
175 return None;
176 }
177 let line = line[border.len_utf8()..].trim_start_matches([' ', '\t']);
178 let remainder = line.strip_prefix("Session:")?;
179 let whitespace = remainder
180 .chars()
181 .take_while(|character| matches!(character, ' ' | '\t'))
182 .count();
183 if whitespace < 3 {
184 return None;
185 }
186 let remainder = remainder.trim_start_matches([' ', '\t']);
187 if remainder.len() < CODEX_PTY_SESSION_ID_BYTES {
188 return None;
189 }
190 let (candidate, trailing) = remainder.split_at(CODEX_PTY_SESSION_ID_BYTES);
191 if !is_canonical_codex_thread_id(candidate) {
192 return None;
193 }
194 let trailing = trailing.trim_matches([' ', '\t']);
195 (trailing.chars().count() == 1 && trailing.starts_with(border))
196 .then(|| candidate.to_owned())
197}
198
199fn is_canonical_codex_thread_id(candidate: &str) -> bool {
200 candidate.len() == CODEX_PTY_SESSION_ID_BYTES
201 && candidate.bytes().enumerate().all(|(index, byte)| {
202 if matches!(index, 8 | 13 | 18 | 23) {
203 byte == b'-'
204 } else {
205 byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
206 }
207 })
208}
209
210fn explicit_kimi_session_id(
211 line: &str,
212 line_complete: bool,
213 allow_status_panel: bool,
214) -> Option<String> {
215 let mut line = line.trim_start_matches([' ', '\t']);
216 let framed = line.starts_with(['│', '┃', '║']);
217 if framed {
218 let border_bytes = line
219 .chars()
220 .next()
221 .expect("framed line has a leading border")
222 .len_utf8();
223 line = line[border_bytes..].trim_start_matches([' ', '\t']);
224 }
225 let (remainder, minimum_space) = match line.strip_prefix("Session:") {
226 Some(remainder) => (remainder, 1),
227 None if allow_status_panel => (line.strip_prefix("Session")?, 2),
228 None => return None,
229 };
230 let whitespace = remainder
231 .chars()
232 .take_while(|character| matches!(character, ' ' | '\t'))
233 .count();
234 if whitespace < minimum_space {
235 return None;
236 }
237 let remainder = remainder.trim_start_matches([' ', '\t']);
238 if !remainder.starts_with("session_") {
239 return None;
240 }
241 let token_bytes = remainder
242 .bytes()
243 .take_while(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
244 .count();
245 let token = &remainder[..token_bytes];
246 if token.len() <= "session_".len() || token.len() > KIMI_PTY_SESSION_ID_MAX_BYTES {
247 return None;
248 }
249 let trailing = &remainder[token_bytes..];
250 if trailing.is_empty() {
251 return line_complete.then(|| token.to_owned());
252 }
253 let trailing = trailing.trim_matches([' ', '\t']);
254 (trailing.is_empty()
255 || (framed && matches!(trailing, "│" | "┃" | "║")))
256 .then(|| token.to_owned())
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn kimi_identity_is_ansi_aware_and_chunk_safe() {
265 let mut extractor = KimiPtySessionIdentityExtractor::default();
266 assert_eq!(extractor.push("\u{1b}[2;"), None);
267 assert_eq!(extractor.push("1mSess"), None);
268 assert_eq!(extractor.push("ion:\u{1b}[0m sess"), None);
269 assert_eq!(extractor.push("ion_ab-19\r").unwrap().id, "session_ab-19");
270 assert_eq!(extractor.push("Session: session_other\n"), None);
271 }
272
273 #[test]
274 fn kimi_identity_rejects_echoes_unsafe_tokens_and_ansi_payloads() {
275 for output in [
276 "user echoed Session: session_injected\n",
277 "Session: session_bad/path\n",
278 "Session: session_bad:tail\n",
279 "Session: session_\n",
280 "Session: 7d8a6f4e\n",
281 "Session session_status-only\n",
282 "\u{1b}]0;Session: session_title\u{7}ready\n",
283 "\u{1b}PSession: session_dcs\u{1b}\\ready\n",
284 "Sess\u{8}ion: session_backspace\n",
285 ] {
286 let mut extractor = KimiPtySessionIdentityExtractor::default();
287 assert_eq!(extractor.push(output), None, "{output:?}");
288 }
289 }
290
291 #[test]
292 fn kimi_status_screen_accepts_the_exact_colonless_framed_label() {
293 let mut extractor = KimiPtySessionIdentityExtractor::default();
294 let identity = extractor
295 .observe_screen(
296 "╭ Status ─────────────╮\n │ Session session_c63be266-18dc-45dc-8a5c-50831076c260 │\n ╰──────────────────────╯",
297 )
298 .expect("Kimi status identity");
299 assert_eq!(
300 identity.id,
301 "session_c63be266-18dc-45dc-8a5c-50831076c260"
302 );
303 assert_eq!(extractor.push("Session: session_other\n"), None);
304 }
305
306 #[test]
307 fn stream_reset_never_joins_identity_across_a_transport_gap() {
308 let mut extractor = KimiPtySessionIdentityExtractor::default();
309 assert_eq!(extractor.push("Session: sess"), None);
310 extractor.reset_stream();
311 assert_eq!(extractor.push("ion_false\n"), None);
312 assert_eq!(
313 extractor.push("Session: session_true\n").unwrap().id,
314 "session_true"
315 );
316 }
317
318 #[test]
319 fn codex_status_screen_accepts_only_the_exact_framed_session_uuid() {
320 let mut extractor = CodexPtySessionIdentityExtractor::default();
321 let identity = extractor
322 .observe_screen(
323 "╭────────────────────────────────────────────╮\n│ Model: gpt-5.4 │\n│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │\n╰────────────────────────────────────────────╯",
324 )
325 .expect("Codex status identity");
326 assert_eq!(identity.key, ProviderSessionKey::SessionId);
327 assert_eq!(identity.id, "0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e");
328 assert_eq!(identity.transcript_path, None);
329 assert_eq!(
330 extractor.observe_screen(
331 "│ Session: 11111111-1111-4111-8111-111111111111 │"
332 ),
333 None
334 );
335 }
336
337 #[test]
338 fn codex_status_screen_rejects_unframed_echoes_and_noncanonical_ids() {
339 for screen in [
340 "Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e",
341 "│ user echoed Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │",
342 "│ Session 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │",
343 "│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │",
344 "│ Session: 0F0F3C13-6CF9-4AA4-8B80-7D49C2F1BE2E │",
345 "│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e trailing │",
346 "│ Session: 0f0f3c13/6cf9-4aa4-8b80-7d49c2f1be2e │",
347 ] {
348 let mut extractor = CodexPtySessionIdentityExtractor::default();
349 assert_eq!(extractor.observe_screen(screen), None, "{screen:?}");
350 }
351 }
352}