Skip to main content

ytil_agents/agent/
session.rs

1use std::fmt::Display;
2use std::fmt::Formatter;
3use std::path::PathBuf;
4use std::str::FromStr;
5
6use jiff::Timestamp;
7use rootcause::option_ext::OptionExt;
8use rootcause::report;
9
10use crate::agent::Agent;
11
12const SEARCH_TEXT_MAX_BYTES: usize = 32 * 1024;
13
14#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct SessionKey {
16    agent: Agent,
17    id: String,
18}
19
20impl SessionKey {
21    pub fn new(agent: Agent, id: impl Into<String>) -> Self {
22        Self { agent, id: id.into() }
23    }
24
25    pub const fn agent(&self) -> Agent {
26        self.agent
27    }
28
29    pub fn id(&self) -> &str {
30        &self.id
31    }
32}
33
34impl Display for SessionKey {
35    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
36        write!(f, "{}:{}", self.agent.name(), self.id)
37    }
38}
39
40impl FromStr for SessionKey {
41    type Err = rootcause::Report;
42
43    fn from_str(value: &str) -> Result<Self, Self::Err> {
44        let Some((agent, id)) = value.split_once(':') else {
45            return Err(report!("invalid session key").attach(format!("value={value}")));
46        };
47        let agent =
48            Agent::from_name(agent).map_err(|err| report!("invalid session key agent").attach(err.to_string()))?;
49        if id.is_empty() {
50            return Err(report!("invalid session key").attach(format!("value={value}")));
51        }
52        Ok(Self::new(agent, id))
53    }
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct Session {
58    pub id: String,
59    pub agent: Agent,
60    pub name: String,
61    pub search_text: String,
62    pub workspace: PathBuf,
63    pub path: PathBuf,
64    pub created_at: Timestamp,
65    pub updated_at: Timestamp,
66}
67
68impl Session {
69    pub fn new(
70        agent: Agent,
71        session_id: String,
72        workspace_dir: PathBuf,
73        path: PathBuf,
74        name: Option<String>,
75        created_at: Timestamp,
76    ) -> Self {
77        let name = name.filter(|name| !name.trim().is_empty()).unwrap_or_else(|| {
78            workspace_dir
79                .file_name()
80                .and_then(|name| name.to_str())
81                .filter(|name| !name.is_empty())
82                .map_or_else(|| session_id.clone(), str::to_owned)
83        });
84
85        Self {
86            id: session_id,
87            agent,
88            search_text: name.clone(),
89            name,
90            workspace: workspace_dir,
91            path,
92            created_at,
93            updated_at: created_at,
94        }
95    }
96
97    /// Build the argv required to resume this session with its owning agent CLI.
98    ///
99    /// # Errors
100    /// Returns an error when the workspace path is not UTF-8 or the agent has no
101    /// supported resume command.
102    pub fn build_resume_command(&self) -> rootcause::Result<(&'static str, Vec<String>)> {
103        let workspace = self.workspace.to_str().context("non-utf8 workspace dir".to_owned())?;
104        match self.agent {
105            Agent::Claude => Ok(("claude", vec!["--resume".into(), self.id.clone()])),
106            Agent::Codex => Ok((
107                "codex",
108                self.build_codex_resume_args(workspace, std::env::var_os("ZELLIJ").is_some()),
109            )),
110            Agent::Cursor => Ok((
111                "cursor-agent",
112                vec![
113                    "--resume".into(),
114                    self.id.clone(),
115                    "--workspace".into(),
116                    workspace.into(),
117                ],
118            )),
119            Agent::Gemini | Agent::Opencode => {
120                Err(report!("resume is not supported for this agent").attach(format!("agent={}", self.agent)))
121            }
122        }
123    }
124
125    fn build_codex_resume_args(&self, workspace: &str, is_zellij: bool) -> Vec<String> {
126        let mut args = vec!["resume".into(), self.id.clone()];
127        // In Zellij, Codex's mouse-aware TUI captures wheel events before
128        // Zellij can use them for inline scrollback. Keep inline mode only
129        // outside Zellij, where terminal scrollback works as intended.
130        if !is_zellij {
131            args.push("--no-alt-screen".into());
132        }
133        args.extend(["--cd".into(), workspace.into()]);
134        args
135    }
136}
137
138#[derive(Debug, Default)]
139pub struct SearchTextBuilder {
140    snippets_text: String,
141    first_snippet: Option<String>,
142    last_snippet: Option<String>,
143    reached_limit: bool,
144}
145
146impl SearchTextBuilder {
147    pub fn push(&mut self, raw: &str) {
148        if self.reached_limit {
149            return;
150        }
151
152        let snippet = raw.split_whitespace().collect::<Vec<_>>().join(" ");
153        let Some(snippet) = (!snippet.is_empty()).then_some(snippet) else {
154            return;
155        };
156        if self.last_snippet.as_ref().is_some_and(|last| last == &snippet) {
157            return;
158        }
159        if self.first_snippet.is_none() {
160            self.first_snippet = Some(snippet.clone());
161        }
162
163        self.reached_limit = !push_normalized_snippet(&mut self.snippets_text, &mut self.last_snippet, &snippet);
164    }
165
166    pub fn build(self, fallback: &str) -> String {
167        let fallback = fallback.split_whitespace().collect::<Vec<_>>().join(" ");
168        let Some(fallback) = (!fallback.is_empty()).then_some(fallback) else {
169            return self.snippets_text;
170        };
171
172        if self.first_snippet.as_ref().is_some_and(|first| first == &fallback) {
173            return self.snippets_text;
174        }
175
176        let mut search_text = String::new();
177        let mut last_snippet = None::<String>;
178        if !push_normalized_snippet(&mut search_text, &mut last_snippet, &fallback) {
179            return search_text;
180        }
181        if self.snippets_text.is_empty() {
182            return search_text;
183        }
184
185        let separator_len = usize::from(!search_text.is_empty());
186        if search_text.len().saturating_add(separator_len) >= SEARCH_TEXT_MAX_BYTES {
187            return search_text;
188        }
189        if !search_text.is_empty() {
190            search_text.push(' ');
191        }
192
193        let remaining = SEARCH_TEXT_MAX_BYTES.saturating_sub(search_text.len());
194        if let Some(truncated) = truncate_to_boundary(&self.snippets_text, remaining) {
195            search_text.push_str(truncated);
196        }
197
198        search_text
199    }
200}
201
202fn push_normalized_snippet(search_text: &mut String, last_snippet: &mut Option<String>, snippet: &str) -> bool {
203    let separator_len = usize::from(!search_text.is_empty());
204    if search_text.len().saturating_add(separator_len) >= SEARCH_TEXT_MAX_BYTES {
205        return false;
206    }
207    if !search_text.is_empty() {
208        search_text.push(' ');
209    }
210
211    let remaining = SEARCH_TEXT_MAX_BYTES.saturating_sub(search_text.len());
212    if remaining == 0 {
213        return false;
214    }
215
216    let snippet_len = snippet.len();
217    truncate_to_boundary(snippet, remaining).is_some_and(|truncated| {
218        let is_full_snippet = truncated.len() == snippet_len;
219        search_text.push_str(truncated);
220        *last_snippet = Some(snippet.to_owned());
221        is_full_snippet
222    })
223}
224
225fn truncate_to_boundary(text: &str, max_bytes: usize) -> Option<&str> {
226    if max_bytes == 0 {
227        return None;
228    }
229    if text.len() <= max_bytes {
230        return Some(text);
231    }
232
233    let mut end = 0;
234    for (idx, ch) in text.char_indices() {
235        let next = idx.saturating_add(ch.len_utf8());
236        if next > max_bytes {
237            break;
238        }
239        end = next;
240    }
241
242    (end > 0).then(|| text.get(..end)).flatten()
243}
244
245#[cfg(test)]
246mod tests {
247    use jiff::Timestamp;
248    use tempfile::tempdir;
249    use test_that::prelude::*;
250
251    use super::*;
252
253    #[test]
254    fn test_session_key_string_round_trip_uses_agent_session_format() {
255        let key_result = "codex:session-id".parse::<SessionKey>();
256        assert_that!(key_result, ok(anything()));
257        let key = key_result.expect("session key should parse");
258
259        assert_that!(key, eq(SessionKey::new(Agent::Codex, "session-id")));
260        assert_that!(key.to_string(), eq("codex:session-id"));
261    }
262
263    #[test]
264    fn test_build_resume_command_matches_agent() {
265        let tempdir = tempdir().expect("tempdir should be created");
266        let workspace = tempdir.path().join("workspace");
267        let path = tempdir.path().join("session.jsonl");
268        std::fs::create_dir_all(&workspace).expect("workspace should be created");
269        let created_at = Timestamp::from_millisecond(1).expect("test timestamp should be valid");
270
271        let claude = Session {
272            agent: Agent::Claude,
273            id: "session-id".into(),
274            workspace: workspace.clone(),
275            name: "session-name".into(),
276            search_text: "session-name".into(),
277            path,
278            created_at,
279            updated_at: created_at,
280        };
281        let codex = Session {
282            agent: Agent::Codex,
283            ..claude.clone()
284        };
285        let cursor = Session {
286            agent: Agent::Cursor,
287            ..claude.clone()
288        };
289
290        let claude_command_result = claude.build_resume_command();
291        assert_that!(claude_command_result, ok(anything()));
292        let (_, claude_args) = claude_command_result.expect("Claude session should build resume command");
293        assert_that!(claude_args, eq(vec!["--resume".to_owned(), "session-id".to_owned()]));
294        let workspace_str = workspace.to_str().expect("workspace test path should be utf8");
295        assert_that!(
296            codex.build_codex_resume_args(workspace_str, false),
297            eq(vec![
298                "resume".to_owned(),
299                "session-id".to_owned(),
300                "--no-alt-screen".to_owned(),
301                "--cd".to_owned(),
302                workspace_str.to_owned(),
303            ])
304        );
305        assert_that!(
306            codex.build_codex_resume_args(workspace_str, true),
307            eq(vec![
308                "resume".to_owned(),
309                "session-id".to_owned(),
310                "--cd".to_owned(),
311                workspace_str.to_owned(),
312            ])
313        );
314        let cursor_command_result = cursor.build_resume_command();
315        assert_that!(cursor_command_result, ok(anything()));
316        let (_, cursor_args) = cursor_command_result.expect("Cursor session should build resume command");
317        assert_that!(
318            cursor_args,
319            eq(vec![
320                "--resume".to_owned(),
321                "session-id".to_owned(),
322                "--workspace".to_owned(),
323                workspace_str.to_owned(),
324            ])
325        );
326    }
327
328    #[test]
329    fn test_session_new_sets_search_text_from_resolved_name() {
330        let tempdir = tempdir().expect("tempdir should be created");
331        let workspace = tempdir.path().join("workspace");
332        std::fs::create_dir_all(&workspace).expect("workspace should be created");
333        let created_at = Timestamp::from_millisecond(1).expect("test timestamp should be valid");
334
335        let session = Session::new(
336            Agent::Codex,
337            "session-id".into(),
338            workspace,
339            PathBuf::from("session.jsonl"),
340            Some("hello world".into()),
341            created_at,
342        );
343
344        assert_that!(session.name, eq("hello world"));
345        assert_that!(session.search_text, eq("hello world"));
346    }
347
348    #[test]
349    fn test_search_text_builder_normalizes_dedupes_and_falls_back() {
350        let mut builder = SearchTextBuilder::default();
351        for snippet in ["  fallback  ", "first\nline", "", "first line", "second\tline"] {
352            builder.push(snippet);
353        }
354        let search_text = builder.build("fallback");
355
356        assert_that!(search_text, eq("fallback first line second line"));
357    }
358}