Skip to main content

vtcode_core/subagents/
background.rs

1use anyhow::{Context, Result};
2use std::path::{Path, PathBuf};
3
4use super::constants::SUBAGENT_PREVIEW_LINES;
5use super::types::{PersistedBackgroundRecord, PersistedBackgroundState};
6use crate::utils::session_archive::{SessionListing, SessionSnapshot};
7
8const BACKGROUND_DEMO_AGENT: &str = "background-demo";
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub(crate) struct BackgroundLaunchSpec {
12    pub command: Vec<String>,
13    pub use_pty: bool,
14}
15
16// ─── Background State Persistence ──────────────────────────────────────────
17
18pub(crate) fn background_state_path(workspace_root: &Path) -> PathBuf {
19    workspace_root
20        .join(".vtcode")
21        .join("state")
22        .join("background_subagents.json")
23}
24
25pub(crate) fn load_background_state(workspace_root: &Path) -> Result<PersistedBackgroundState> {
26    let path = background_state_path(workspace_root);
27    if !path.exists() {
28        return Ok(PersistedBackgroundState::default());
29    }
30
31    let raw = std::fs::read_to_string(&path)
32        .with_context(|| format!("Failed to read {}", path.display()))?;
33    serde_json::from_str(&raw).with_context(|| format!("Failed to parse {}", path.display()))
34}
35
36pub(crate) fn persist_background_state(
37    workspace_root: &Path,
38    records: Vec<PersistedBackgroundRecord>,
39) -> Result<()> {
40    let path = background_state_path(workspace_root);
41    if let Some(parent) = path.parent() {
42        std::fs::create_dir_all(parent)
43            .with_context(|| format!("Failed to create {}", parent.display()))?;
44    }
45    let payload = serde_json::to_string_pretty(&PersistedBackgroundState { records })?;
46    std::fs::write(&path, payload)
47        .with_context(|| format!("Failed to write {}", path.display()))?;
48    Ok(())
49}
50
51// ─── Background Command Building ───────────────────────────────────────────
52
53pub(crate) fn build_background_launch_spec(
54    workspace_root: &Path,
55    agent_name: &str,
56    parent_session_id: &str,
57    session_id: &str,
58    prompt: &str,
59    max_turns: Option<usize>,
60    model_override: Option<&str>,
61    reasoning_override: Option<&str>,
62) -> Result<BackgroundLaunchSpec> {
63    if agent_name == BACKGROUND_DEMO_AGENT {
64        let script = workspace_root
65            .join("scripts")
66            .join("demo-background-subagent.sh");
67        return Ok(BackgroundLaunchSpec {
68            command: vec![script.to_string_lossy().into_owned()],
69            use_pty: false,
70        });
71    }
72
73    Ok(BackgroundLaunchSpec {
74        command: build_background_subagent_command(
75            workspace_root,
76            agent_name,
77            parent_session_id,
78            session_id,
79            prompt,
80            max_turns,
81            model_override,
82            reasoning_override,
83        )?,
84        use_pty: true,
85    })
86}
87
88pub fn build_background_subagent_command(
89    workspace_root: &Path,
90    agent_name: &str,
91    parent_session_id: &str,
92    session_id: &str,
93    prompt: &str,
94    max_turns: Option<usize>,
95    model_override: Option<&str>,
96    reasoning_override: Option<&str>,
97) -> Result<Vec<String>> {
98    let executable = std::env::current_exe().context("Failed to resolve current vtcode binary")?;
99    let executable =
100        resolve_background_subagent_executable_for_workspace(workspace_root, &executable);
101    let mut command = vec![
102        executable.to_string_lossy().into_owned(),
103        "background-subagent".to_string(),
104        "--workspace".to_string(),
105        workspace_root.to_string_lossy().into_owned(),
106        "--agent-name".to_string(),
107        agent_name.to_string(),
108        "--parent-session-id".to_string(),
109        parent_session_id.to_string(),
110        "--session-id".to_string(),
111        session_id.to_string(),
112        "--prompt".to_string(),
113        prompt.to_string(),
114    ];
115
116    if let Some(max_turns) = max_turns {
117        command.push("--max-turns".to_string());
118        command.push(max_turns.to_string());
119    }
120    if let Some(model_override) = model_override
121        && !model_override.trim().is_empty()
122    {
123        command.push("--model-override".to_string());
124        command.push(model_override.to_string());
125    }
126    if let Some(reasoning_override) = reasoning_override
127        && !reasoning_override.trim().is_empty()
128    {
129        command.push("--reasoning-override".to_string());
130        command.push(reasoning_override.to_string());
131    }
132
133    Ok(command)
134}
135
136fn resolve_background_subagent_executable_for_workspace(
137    workspace_root: &Path,
138    current_exe: &Path,
139) -> PathBuf {
140    let workspace_target = workspace_root.join("target");
141    if current_exe.starts_with(&workspace_target) {
142        return current_exe.to_path_buf();
143    }
144
145    let binary_name = format!("vtcode{}", std::env::consts::EXE_SUFFIX);
146    for profile in ["debug", "release"] {
147        let candidate = workspace_target.join(profile).join(&binary_name);
148        if candidate.is_file() {
149            return candidate;
150        }
151    }
152
153    current_exe.to_path_buf()
154}
155
156pub fn background_record_id(agent_name: &str) -> String {
157    format!("background-{}", sanitize_component(agent_name))
158}
159
160fn sanitize_component(value: &str) -> String {
161    value
162        .chars()
163        .map(|ch| {
164            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
165                ch
166            } else {
167                '-'
168            }
169        })
170        .collect::<String>()
171        .trim_matches('-')
172        .to_string()
173}
174
175// ─── Preview Utilities ─────────────────────────────────────────────────────
176
177pub fn extract_tail_lines(content: &str, max_lines: usize) -> String {
178    let lines: Vec<_> = content.lines().collect();
179    let start = lines.len().saturating_sub(max_lines);
180    lines[start..].join("\n")
181}
182
183pub fn load_archive_preview(path: &Path) -> Result<String> {
184    let listing = load_session_listing(path)?;
185    Ok(extract_tail_lines(
186        &listing.snapshot.transcript.join("\n"),
187        SUBAGENT_PREVIEW_LINES,
188    ))
189}
190
191fn load_session_listing(path: &Path) -> Result<SessionListing> {
192    let raw = std::fs::read_to_string(path)
193        .with_context(|| format!("Failed to read session archive {}", path.display()))?;
194    let snapshot: SessionSnapshot = serde_json::from_str(&raw)
195        .with_context(|| format!("Failed to parse session archive {}", path.display()))?;
196    Ok(SessionListing {
197        path: path.to_path_buf(),
198        snapshot,
199    })
200}
201
202#[must_use]
203pub(crate) fn exec_session_is_running(session: &crate::tools::types::VTCodeExecSession) -> bool {
204    matches!(
205        session.lifecycle_state,
206        Some(crate::tools::types::VTCodeSessionLifecycleState::Running)
207    )
208}
209
210pub fn subagent_display_label(spec: &vtcode_config::SubagentSpec) -> String {
211    spec.nickname_candidates
212        .first()
213        .cloned()
214        .unwrap_or_else(|| spec.name.clone())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::{
220        build_background_launch_spec, resolve_background_subagent_executable_for_workspace,
221    };
222    use std::fs;
223    use std::path::Path;
224    use tempfile::TempDir;
225
226    #[test]
227    fn prefers_workspace_built_binary_when_current_exe_is_external() {
228        let temp_dir = TempDir::new().expect("temp dir");
229        let workspace_root = temp_dir.path();
230        let candidate = workspace_root.join("target/debug/vtcode");
231        fs::create_dir_all(candidate.parent().expect("parent")).expect("mkdir");
232        fs::write(&candidate, b"binary").expect("write candidate");
233
234        let resolved = resolve_background_subagent_executable_for_workspace(
235            workspace_root,
236            Path::new("/usr/local/bin/vtcode"),
237        );
238
239        assert_eq!(resolved, candidate);
240    }
241
242    #[test]
243    fn keeps_current_exe_when_already_running_workspace_binary() {
244        let temp_dir = TempDir::new().expect("temp dir");
245        let workspace_root = temp_dir.path();
246        let current = workspace_root.join("target/debug/vtcode");
247        fs::create_dir_all(current.parent().expect("parent")).expect("mkdir");
248        fs::write(&current, b"binary").expect("write current");
249
250        let resolved =
251            resolve_background_subagent_executable_for_workspace(workspace_root, &current);
252
253        assert_eq!(resolved, current);
254    }
255
256    #[test]
257    fn background_demo_launch_uses_direct_script_pipe_session() {
258        let temp_dir = TempDir::new().expect("temp dir");
259        let workspace_root = temp_dir.path();
260
261        let launch = build_background_launch_spec(
262            workspace_root,
263            "background-demo",
264            "parent",
265            "child",
266            "Report readiness once.",
267            Some(4),
268            None,
269            None,
270        )
271        .expect("background demo launch");
272
273        assert!(!launch.use_pty);
274        assert_eq!(
275            launch.command,
276            vec![
277                workspace_root
278                    .join("scripts")
279                    .join("demo-background-subagent.sh")
280                    .to_string_lossy()
281                    .into_owned()
282            ]
283        );
284    }
285}