1use crate::backend::obj;
9use super::sanitize::sanitize_ambient_text;
10
11pub(crate) fn ambient_call_cwd() -> Option<std::path::PathBuf> {
16 let dir = crate::backend::base::get_mux_data_dir().join("ambient-calls");
17 std::fs::create_dir_all(&dir).ok()?;
18 Some(dir)
19}
20
21pub const AMBIENT_MODEL: &str = "claude-haiku-4-5-20251001";
23
24pub async fn invoke_haiku(
38 cli_path: &str,
39 prompt: &str,
40 meta: &obj::MetaMapType,
41 cancel: tokio_util::sync::CancellationToken,
42 timeout: std::time::Duration,
43) -> Result<(String, Option<crate::agents::TokenCounts>), String> {
44 let auth_env = crate::backend::blockcontroller::cmd_env_of(meta);
45
46 let mut cmd = crate::server::cli_handlers::make_cli_cmd(cli_path);
47 cmd.args(["-p", "--output-format", "stream-json", "--verbose",
53 "--model", AMBIENT_MODEL,
54 "--tools", "", "--max-turns", "1",
55 "--no-session-persistence", "--disable-slash-commands",
56 "--strict-mcp-config",
57 "--system-prompt", crate::ambient::prompt::AMBIENT_SYSTEM_PROMPT])
58 .envs(&auth_env);
59 if let Some(dir) = ambient_call_cwd() {
65 cmd.current_dir(dir);
66 }
67 let mut child = cmd
68 .stdin(std::process::Stdio::piped())
69 .stdout(std::process::Stdio::piped())
70 .stderr(std::process::Stdio::null())
71 .kill_on_drop(true)
72 .spawn()
73 .map_err(|e| format!("failed to spawn the ambient CLI: {e}"))?;
74
75 if let Some(mut stdin) = child.stdin.take() {
76 use tokio::io::AsyncWriteExt;
77 stdin.write_all(prompt.as_bytes()).await
78 .map_err(|e| format!("ambient CLI stdin write: {e}"))?;
79 stdin.shutdown().await
80 .map_err(|e| format!("ambient CLI stdin shutdown: {e}"))?;
81 }
82
83 let mut stdout_pipe = child.stdout.take()
90 .ok_or_else(|| "ambient CLI: no stdout pipe".to_string())?;
91 let stdout_task = tokio::spawn(async move {
92 use tokio::io::AsyncReadExt;
93 let mut buf = Vec::new();
94 let _ = stdout_pipe.read_to_end(&mut buf).await;
95 buf
96 });
97
98 let status = tokio::select! {
99 biased;
100 _ = cancel.cancelled() => {
101 let _ = child.kill().await;
102 return Err("cancelled: superseded by a newer request".to_string());
103 }
104 result = tokio::time::timeout(timeout, child.wait()) => {
105 result.map_err(|_| format!("ambient CLI timed out after {}s", timeout.as_secs()))?
106 .map_err(|e| format!("ambient CLI wait: {e}"))?
107 }
108 };
109
110 if !status.success() {
111 return Err(format!("ambient CLI exited with status {status}"));
112 }
113
114 let stdout_bytes = stdout_task.await
115 .map_err(|e| format!("ambient CLI stdout reader task: {e}"))?;
116 let stdout = String::from_utf8_lossy(&stdout_bytes);
117 Ok(parse_cli_stream(&stdout))
121}
122
123fn parse_cli_stream(stdout: &str) -> (String, Option<crate::agents::TokenCounts>) {
127 let mut last_text = String::new();
128 let mut tokens: Option<crate::agents::TokenCounts> = None;
129 for line in stdout.lines() {
130 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else { continue };
131 match val.get("type").and_then(|v| v.as_str()) {
132 Some("assistant") => {
133 if let Some(content) = val.get("message")
134 .and_then(|m| m.get("content"))
135 .and_then(|c| c.as_array())
136 {
137 for block in content {
138 if block.get("type").and_then(|v| v.as_str()) == Some("text") {
139 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
140 last_text = text.trim().to_string();
141 }
142 }
143 }
144 }
145 }
146 Some("result") => {
147 tokens = Some(crate::agents::translator::claude::parse_usage(val.get("usage")));
148 }
149 _ => {}
150 }
151 }
152 (sanitize_ambient_text(&last_text), tokens)
153}
154
155#[cfg(test)]
156mod ambient_call_cwd_tests {
157 #[test]
160 fn side_calls_run_in_their_own_scratch_dir() {
161 let dir = super::ambient_call_cwd().expect("the scratch dir can be made");
162 assert!(dir.is_dir());
163 assert!(dir.ends_with("ambient-calls"));
164 assert!(dir.starts_with(crate::backend::base::get_mux_data_dir()));
165 assert_ne!(Some(dir.as_path()), dirs::home_dir().as_deref());
166 }
167}
168
169#[cfg(test)]
170mod ambient_cli_stream_tests {
171 use super::*;
172
173 #[test]
174 fn an_empty_fence_is_empty_text_but_the_usage_survives() {
175 let out = [
176 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"```\n```"}]}}"#,
177 r#"{"type":"result","usage":{"input_tokens":235,"output_tokens":4}}"#,
178 ].join("\n");
179 let (text, tokens) = parse_cli_stream(&out);
180 assert_eq!(text, "");
181 assert!(tokens.is_some());
182 }
183
184 #[test]
185 fn the_last_assistant_text_wins_and_thinking_blocks_are_ignored() {
186 let out = [
187 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"hmm"}]}}"#,
188 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Run the tests"}]}}"#,
189 r#"{"type":"result","usage":{"input_tokens":1,"output_tokens":2}}"#,
190 ].join("\n");
191 assert_eq!(parse_cli_stream(&out).0, "Run the tests");
192 }
193
194 #[test]
195 fn a_transcript_with_no_result_line_has_no_usage() {
196 let out = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"x"}]}}"#;
197 assert!(parse_cli_stream(out).1.is_none());
198 }
199}
200
201#[cfg(test)]
206mod live_cli_smoke {
207 use super::*;
208
209 #[tokio::test]
210 #[ignore = "spawns the real claude CLI"]
211 async fn live_cli_answers_a_thin_prompt_with_nothing_instead_of_a_refusal() {
212 let meta = obj::MetaMapType::new();
213 let prompt = crate::ambient::prompt::build_next_prompt_prompt("[user] hi");
214 let (text, tokens) = invoke_haiku(
215 "claude",
216 &prompt,
217 &meta,
218 tokio_util::sync::CancellationToken::new(),
219 crate::ambient::purpose::NEXT_PROMPT_SUGGESTION.timeout,
220 )
221 .await
222 .expect("the CLI ran");
223 assert!(tokens.is_some(), "usage is reported");
224 assert_ne!(crate::ambient::reply::parse(&text), crate::ambient::reply::Parsed::Malformed, "unexpected reply: {text}");
226 assert!(!text.to_lowercase().contains("i don't have"), "{text}");
227 }
228
229 #[tokio::test]
234 #[ignore = "spawns the real claude CLI, about a dozen calls"]
235 async fn live_cli_reply_format_holds_across_digests() {
236 use crate::ambient::reply::{parse, Parsed};
237 let digests = [
238 "[user] fix the login redirect loop\n[assistant] Found it: the session cookie was set after the redirect. Fixed in auth.ts; the login flow works now.",
240 "[user] add a dark mode toggle\n[assistant] Added the toggle to the settings panel and wired it to the theme store. I haven't written tests yet.",
241 "[user] why is CI red\n[tool] Bash\n[error] npm test exited 1\n[assistant] Two snapshot tests fail after the button restyle; the snapshots need updating.",
242 "[user] profile the slow search\n[tool] Bash\n[assistant] Search spends 80% of its time re-sorting the whole index on every keystroke.",
243 "[user] bump the version\n[assistant] Bumped to 2.4.0 in package.json and the lockfile. The changelog still lists these changes under Unreleased.",
244 "[user] write the migration\n[assistant] The migration adds the column with a default and backfills in batches of 1000. It hasn't been run against staging.",
245 "[user] thanks, that's all\n[assistant] You're welcome. Everything is merged and deployed.",
247 "[user] merge it\n[assistant] Merged. The branch is deleted and main is green.",
248 "[user] hi\n[assistant] Hello.",
250 "[user] ok\n[assistant] Done.",
251 "[tool] Read\n[tool] Read\n[tool] Grep\n[assistant] Looking.",
252 "[user] continue\n[assistant] Continuing with the next file.",
253 ];
254 let meta = obj::MetaMapType::new();
255 let mut bad = Vec::new();
256 let mut failed = 0;
257 for digest in digests {
258 let prompt = crate::ambient::prompt::build_next_prompt_prompt(digest);
259 let text = match invoke_haiku(
262 "claude",
263 &prompt,
264 &meta,
265 tokio_util::sync::CancellationToken::new(),
266 crate::ambient::purpose::NEXT_PROMPT_SUGGESTION.timeout,
267 )
268 .await
269 {
270 Ok((text, _)) => text,
271 Err(e) => {
272 println!("CLI failed: {e}");
273 failed += 1;
274 continue;
275 }
276 };
277 let parsed = parse(&text);
278 println!("{parsed:?} <- {text:?}");
279 if parsed == Parsed::Malformed {
280 bad.push(text);
281 }
282 }
283 println!("{failed} of {} calls failed in the CLI", digests.len());
284 assert!(failed < digests.len(), "no call completed");
285 assert!(bad.is_empty(), "replies not in the format: {bad:?}");
286 }
287
288 #[tokio::test]
292 #[ignore = "calls the real claude CLI; run with --ignored"]
293 async fn live_cli_skips_a_wait_the_gate_cannot_see() {
294 use crate::ambient::reply::{parse, Parsed};
295 let digests = [
296 "[user] pick a cache\n[assistant] Redis or an in-process LRU would both work. Tell me which one and I'll start.",
297 "[user] plan the refactor\n[assistant] Here's the plan: split the parser, then move the tests. Let me know if that works for you before I begin.",
298 "[user] deploy it\n[assistant] Ready to deploy to production. Waiting for your go-ahead.",
299 ];
300 let meta = obj::MetaMapType::new();
301 let mut answered = Vec::new();
302 for digest in digests {
303 let prompt = crate::ambient::prompt::build_next_prompt_prompt(digest);
304 let Ok((text, _)) = invoke_haiku(
305 "claude",
306 &prompt,
307 &meta,
308 tokio_util::sync::CancellationToken::new(),
309 crate::ambient::purpose::NEXT_PROMPT_SUGGESTION.timeout,
310 )
311 .await
312 else {
313 continue;
314 };
315 let parsed = parse(&text);
316 println!("{parsed:?} <- {text:?}");
317 if parsed != Parsed::Skip {
318 answered.push(text);
319 }
320 }
321 assert!(answered.is_empty(), "answered a message that waits for the user: {answered:?}");
322 }
323
324 #[tokio::test]
329 #[ignore = "spawns the real claude CLI, about ten calls"]
330 async fn live_cli_reply_format_holds_for_every_purpose() {
331 use crate::ambient::prompt::*;
332 use crate::ambient::reply::{parse, Parsed};
333 let task = "Investigate the repository at C:\\work\\app. Read src/auth and report every place the session \
334 cookie is set, with file:line references. Do not change any files.";
335 let prompts = [
336 ("title, create", build_session_title_prompt("", Some("fix the login redirect loop"), None, 7)),
337 ("title, create, nudge", build_session_title_prompt("", Some("continue"), None, 7)),
338 ("title, maintain, same goal", build_session_title_prompt("Fix login redirect loop", Some("also check the logout path"), None, 7)),
339 ("title, maintain, new goal", build_session_title_prompt("Fix login redirect loop", Some("now let's build the billing page"), None, 7)),
340 ("title, from activity", build_session_title_prompt("", None, Some("[user] add a dark mode toggle\n[assistant] Added it."), 7)),
341 ("subagent name", build_subagent_name_prompt(task)),
342 ("dispatch name", build_dispatch_name_prompt(task)),
343 ("narration", narration_prompt("background_task", "task dev").unwrap()),
344 ("preview", build_definition_summary_prompt("[user] add a dark mode toggle\n[assistant] Added it and its tests.")),
345 ("preview, thin", build_definition_summary_prompt("[user] hi\n[assistant] Hello.")),
346 ];
347 let meta = obj::MetaMapType::new();
348 let mut bad = Vec::new();
349 for (name, prompt) in prompts {
350 let text = match invoke_haiku(
351 "claude",
352 &prompt,
353 &meta,
354 tokio_util::sync::CancellationToken::new(),
355 crate::ambient::purpose::ACTIVITY_SUMMARY.timeout,
356 )
357 .await
358 {
359 Ok((text, _)) => text,
360 Err(e) => {
361 println!("{name}: CLI failed: {e}");
362 continue;
363 }
364 };
365 let parsed = parse(&text);
366 println!("{name}: {parsed:?} <- {text:?}");
367 if parsed == Parsed::Malformed {
368 bad.push(format!("{name}: {text}"));
369 }
370 }
371 assert!(bad.is_empty(), "replies not in the format: {bad:?}");
372 }
373}