Skip to main content

agent_engine/session/
client_args.rs

1//! Client-side resolution of path-like `SessionConfig` arguments (T4, F26).
2//!
3//! When a thin client ships `--system ./prompt.md` to the daemon, the
4//! *string* `./prompt.md` must be resolved against the **client's** cwd, not
5//! the daemon's.  This module provides [`resolve_client_session_args`] which
6//! rewrites `SessionConfig` in place before `Hello` is sent.
7//!
8//! ## Path-like heuristic for `--system`
9//!
10//! A value is treated as a file path when it:
11//!   - starts with `/`, `./`, `../`, or `~`, **or**
12//!   - ends with `.md` or `.txt` (case-insensitive)
13//!
14//! **and** contains no embedded newlines or runs of whitespace longer than a
15//! single space (prose rarely looks like a path).
16//!
17//! When path-like AND the file is readable, the value is replaced with the
18//! file's **contents**.  When path-like but missing/unreadable, an error is
19//! returned so the client can exit non-zero with a helpful message.
20//!
21//! Non-path-like values pass through untouched (they are literal prompt text).
22//!
23//! ## `--prompt-manifest`
24//!
25//! Canonicalized against `client_cwd`.  Missing → error with the resolved path.
26
27use std::path::{Path, PathBuf};
28
29use super::types::SessionConfig;
30
31/// Returns `true` when the value looks like a file path rather than prose.
32///
33/// Criteria (any of):
34/// - starts with `/`, `./`, `../`, or `~`
35/// - ends with `.md` or `.txt` (case-insensitive)
36///
37/// **and** contains no newlines and no multi-space runs (prose guard).
38pub fn looks_path_like(val: &str) -> bool {
39    // Prose guard: newlines or runs of 2+ spaces → not a path.
40    if val.contains('\n') || val.contains("  ") {
41        return false;
42    }
43    let trimmed = val.trim();
44    if trimmed.is_empty() {
45        return false;
46    }
47
48    let starts = trimmed.starts_with('/')
49        || trimmed.starts_with("./")
50        || trimmed.starts_with("../")
51        || trimmed.starts_with('~');
52
53    let lower = trimmed.to_ascii_lowercase();
54    let ends = lower.ends_with(".md") || lower.ends_with(".txt");
55
56    starts || ends
57}
58
59/// Resolve `client_cwd`-relative path to absolute, expanding `~` to the
60/// user's home directory.
61fn resolve_against(val: &str, client_cwd: &Path) -> PathBuf {
62    let trimmed = val.trim();
63    if let Some(rest) = trimmed.strip_prefix('~') {
64        if let Some(home) = dirs::home_dir() {
65            return home.join(rest.strip_prefix('/').unwrap_or(rest));
66        }
67    }
68    let p = Path::new(trimmed);
69    if p.is_absolute() {
70        p.to_path_buf()
71    } else {
72        client_cwd.join(p)
73    }
74}
75
76/// Resolve path-like arguments in `cfg` against `client_cwd`.
77///
78/// - `system`: path-like → read file contents; path-like but missing → Err.
79/// - `prompt_manifest`: canonicalize; missing → Err.
80///
81/// Called from both thin clients (`tui/attach.rs`, `cmd/attach.rs`) **before**
82/// sending `Hello`.  The in-process path (`resolve_system_prompt` in
83/// `agent-core`) is unchanged.
84pub fn resolve_client_session_args(
85    cfg: &mut SessionConfig,
86    client_cwd: &Path,
87) -> Result<(), String> {
88    // ── system ────────────────────────────────────────────────────────────
89    if let Some(ref val) = cfg.system {
90        if looks_path_like(val) {
91            let abs = resolve_against(val, client_cwd);
92            match std::fs::read_to_string(&abs) {
93                Ok(contents) => {
94                    cfg.system = Some(contents);
95                }
96                Err(_) => {
97                    return Err(format!(
98                        "--system {val}: no such file (resolved {})",
99                        abs.display()
100                    ));
101                }
102            }
103        }
104    }
105
106    // ── prompt_manifest ──────────────────────────────────────────────────
107    if let Some(ref val) = cfg.prompt_manifest {
108        let abs = if val.is_absolute() {
109            val.clone()
110        } else {
111            client_cwd.join(val)
112        };
113        if !abs.exists() {
114            return Err(format!(
115                "--prompt-manifest {}: no such file (resolved {})",
116                val.display(),
117                abs.display()
118            ));
119        }
120        cfg.prompt_manifest = Some(abs);
121    }
122
123    Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn path_like_detection() {
132        // Path-like
133        assert!(looks_path_like("./prompt.md"));
134        assert!(looks_path_like("../prompt.md"));
135        assert!(looks_path_like("/tmp/prompt.md"));
136        assert!(looks_path_like("~/prompts/system.md"));
137        assert!(looks_path_like("notes.txt"));
138        assert!(looks_path_like("NOTES.MD"));
139        assert!(looks_path_like("./foo"));
140
141        // Not path-like (prose)
142        assert!(!looks_path_like("You are a helpful assistant"));
143        assert!(!looks_path_like("Be concise and direct. Use tools."));
144        assert!(!looks_path_like("multi\nline\nprompt"));
145        assert!(!looks_path_like("has  double  spaces"));
146        assert!(!looks_path_like(""));
147    }
148
149    #[test]
150    fn relative_resolution_against_cwd() {
151        let cwd = Path::new("/tmp/proj");
152        let abs = resolve_against("./prompt.md", cwd);
153        assert_eq!(abs, PathBuf::from("/tmp/proj/prompt.md"));
154        let abs = resolve_against("../other/p.md", cwd);
155        assert_eq!(abs, PathBuf::from("/tmp/proj/../other/p.md"));
156    }
157
158    #[test]
159    fn absolute_path_unchanged() {
160        let cwd = Path::new("/tmp/proj");
161        let abs = resolve_against("/etc/prompt.md", cwd);
162        assert_eq!(abs, PathBuf::from("/etc/prompt.md"));
163    }
164
165    #[test]
166    fn system_content_replacement() {
167        let dir = tempfile::tempdir().unwrap();
168        let file = dir.path().join("p.md");
169        std::fs::write(&file, "You are PROOF-T4").unwrap();
170
171        let mut cfg = SessionConfig {
172            system: Some("./p.md".into()),
173            ..Default::default()
174        };
175        resolve_client_session_args(&mut cfg, dir.path()).unwrap();
176        assert_eq!(cfg.system.as_deref(), Some("You are PROOF-T4"));
177    }
178
179    #[test]
180    fn system_missing_file_errors() {
181        let dir = tempfile::tempdir().unwrap();
182        let mut cfg = SessionConfig {
183            system: Some("./missing.md".into()),
184            ..Default::default()
185        };
186        let err = resolve_client_session_args(&mut cfg, dir.path()).unwrap_err();
187        assert!(err.contains("--system ./missing.md"), "err={err}");
188        assert!(err.contains("no such file"), "err={err}");
189        // The resolved path must contain the tempdir and filename.
190        assert!(
191            err.contains("missing.md"),
192            "err={err}"
193        );
194    }
195
196    #[test]
197    fn system_prose_passes_through() {
198        let dir = tempfile::tempdir().unwrap();
199        let mut cfg = SessionConfig {
200            system: Some("You are a helpful assistant".into()),
201            ..Default::default()
202        };
203        resolve_client_session_args(&mut cfg, dir.path()).unwrap();
204        assert_eq!(
205            cfg.system.as_deref(),
206            Some("You are a helpful assistant")
207        );
208    }
209
210    #[test]
211    fn prompt_manifest_canonicalized() {
212        let dir = tempfile::tempdir().unwrap();
213        let file = dir.path().join("manifest.toml");
214        std::fs::write(&file, "[prompt]").unwrap();
215
216        let mut cfg = SessionConfig {
217            prompt_manifest: Some(PathBuf::from("manifest.toml")),
218            ..Default::default()
219        };
220        resolve_client_session_args(&mut cfg, dir.path()).unwrap();
221        assert!(cfg.prompt_manifest.as_ref().unwrap().is_absolute());
222    }
223
224    #[test]
225    fn prompt_manifest_missing_errors() {
226        let dir = tempfile::tempdir().unwrap();
227        let mut cfg = SessionConfig {
228            prompt_manifest: Some(PathBuf::from("nope.toml")),
229            ..Default::default()
230        };
231        let err = resolve_client_session_args(&mut cfg, dir.path()).unwrap_err();
232        assert!(err.contains("--prompt-manifest"), "err={err}");
233        assert!(err.contains("no such file"), "err={err}");
234    }
235}