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
16pub(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
51pub(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
175pub 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(¤t, b"binary").expect("write current");
249
250 let resolved =
251 resolve_background_subagent_executable_for_workspace(workspace_root, ¤t);
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}