1use std::path::Path;
2use std::time::{Duration, Instant};
3
4use serde::Deserialize;
5use serde_json::{json, Value};
6
7use crate::bash_background::registry::BgTaskSnapshot;
8use crate::bash_background::BgTaskStatus;
9use crate::context::AppContext;
10use crate::protocol::{RawRequest, Response};
11use crate::response_finalize::{DispatchOutcome, PendingResponse, PendingResponsePoll};
12
13const TEST_FOREGROUND_WAIT_ENV: &str = "AFT_TEST_FOREGROUND_WAIT_MS";
14const DEFAULT_FOREGROUND_WAIT_TIMEOUT_MS: u64 = 30 * 60 * 1000;
15
16#[derive(Debug, Default, Deserialize)]
17#[serde(default)]
18struct BashOrchestrateParams {
19 foreground_orchestrate: bool,
20 block_to_completion: bool,
21 wait: bool,
22 background: bool,
23 pty: bool,
24 timeout: Option<u64>,
25}
26
27pub fn format_foreground_result(snapshot: &BgTaskSnapshot) -> String {
29 let mut rendered = snapshot.output_preview.clone();
30 if snapshot.output_truncated {
31 if let Some(output_path) = snapshot.output_path.as_deref() {
32 rendered.push_str(&format!(
33 "\n[output truncated; full output at {output_path}]"
34 ));
35 }
36 }
37 if snapshot.info.status == BgTaskStatus::TimedOut {
38 rendered.push_str("\n[command timed out]");
39 }
40 if let Some(exit) = snapshot.exit_code.filter(|exit| *exit != 0) {
41 rendered.push_str(&format!("\n[exit code: {exit}]"));
42 }
43 rendered
44}
45
46pub fn format_seconds(ms: u64) -> String {
48 let mut seconds = format!("{:.1}", ms as f64 / 1000.0);
49 if seconds.ends_with(".0") {
50 seconds.truncate(seconds.len() - 2);
51 }
52 format!("{seconds}s")
53}
54
55fn format_background_handoff_tail(task_id: &str) -> String {
56 format!(
57 "{task_id}. A completion reminder will be delivered automatically; use bash_status({{ taskId: \"{task_id}\" }}) to inspect output or bash_kill({{ taskId: \"{task_id}\" }}) to terminate."
58 )
59}
60
61pub fn format_promotion_message(
63 task_id: &str,
64 timeout: Option<u64>,
65 wait_window_ms: u64,
66) -> String {
67 let waited = timeout
68 .map(|timeout| timeout.min(wait_window_ms))
69 .unwrap_or(wait_window_ms);
70 format!(
71 "Foreground bash didn't finish within {} and was promoted to background: {}",
72 format_seconds(waited),
73 format_background_handoff_tail(task_id)
74 )
75}
76
77pub fn format_wait_detach_message(task_id: &str) -> String {
78 format!(
79 "Foreground bash is running in background as {}\nDetached because a user message arrived.",
80 format_background_handoff_tail(task_id)
81 )
82}
83
84pub fn format_background_launch(task_id: &str, pty: bool) -> String {
86 if pty {
87 return format!(
88 "PTY task started: {task_id}. Use bash_status({{ taskId: \"{task_id}\", outputMode: \"screen\" }}) to see the visible terminal, bash_write({{ taskId: \"{task_id}\", input: ... }}) to send keystrokes. A completion reminder fires automatically when the task exits."
89 );
90 }
91 format!(
92 "Background task started: {task_id}. A completion reminder will be delivered automatically; don't poll bash_status."
93 )
94}
95
96pub fn foreground_orchestrate_enabled(req: &RawRequest) -> bool {
97 parse_params(req)
98 .map(|params| params.foreground_orchestrate)
99 .unwrap_or(false)
100}
101
102pub fn build_bash_outcome(
103 req: &RawRequest,
104 ctx: &AppContext,
105 spawn_response: Response,
106) -> DispatchOutcome {
107 if !spawn_response.success {
108 return DispatchOutcome::Immediate(spawn_response);
109 }
110
111 let params = parse_params(req).unwrap_or_default();
112 let Some(task_id) = spawn_response
113 .data
114 .get("task_id")
115 .and_then(Value::as_str)
116 .map(str::to_owned)
117 else {
118 return DispatchOutcome::Immediate(spawn_response);
119 };
120 if spawn_response.data.get("status").and_then(Value::as_str) != Some("running") {
121 return DispatchOutcome::Immediate(spawn_response);
122 }
123
124 let mode = spawn_response
125 .data
126 .get("mode")
127 .and_then(Value::as_str)
128 .unwrap_or("pipes");
129 let is_pty = mode == "pty" || params.pty;
130 if is_pty || params.background {
131 return DispatchOutcome::Immediate(background_launch_response(&req.id, &task_id, is_pty));
132 }
133
134 let request_id = req.id.clone();
135 let session_id = req.session().to_string();
136 let attach_command = "bash".to_string();
137 let detach_on_user_message = params.wait;
138 ctx.bash_background()
139 .register_foreground_task(&session_id, &task_id);
140 if detach_on_user_message {
141 ctx.bash_background()
142 .begin_wait_mode_session(&session_id, &task_id);
143 }
144 let wait_window_ms = select_foreground_wait_window_ms(
145 ctx.config().foreground_wait_window_ms,
146 params.timeout,
147 params.wait,
148 );
149 let deadline = Instant::now() + Duration::from_millis(wait_window_ms);
150 let block_to_completion = params.block_to_completion || params.wait;
151 let timeout = params.timeout;
152 let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
153 let project_root = ctx.config().project_root.clone();
154 let task_id_for_poll = task_id.clone();
155 let request_id_for_poll = request_id.clone();
156 let session_id_for_poll = session_id.clone();
157 let session_id_for_cleanup = session_id.clone();
158
159 let mut poll: PendingResponsePoll = Box::new(move |ctx| {
160 let response = if let Some(snapshot) = poll_bash_status(
163 ctx,
164 &task_id_for_poll,
165 &session_id_for_poll,
166 project_root.as_deref(),
167 &storage_dir,
168 0,
169 ) {
170 if snapshot.info.status.is_terminal() {
171 Some(foreground_result_response(&request_id_for_poll, snapshot))
172 } else if detach_on_user_message
173 && ctx
174 .bash_background()
175 .take_wait_mode_detach(&session_id_for_poll)
176 {
177 Some(detach_wait_mode_bash(
178 ctx,
179 &task_id_for_poll,
180 &session_id_for_poll,
181 &request_id_for_poll,
182 ))
183 } else {
184 match decide_bash_step(
185 snapshot,
186 deadline,
187 block_to_completion,
188 Instant::now(),
189 &request_id_for_poll,
190 ) {
191 BashStep::Done(response) => Some(response),
192 BashStep::Promote => Some(promote_bash(
193 ctx,
194 &task_id_for_poll,
195 &session_id_for_poll,
196 timeout,
197 wait_window_ms,
198 &request_id_for_poll,
199 )),
200 BashStep::Wait => None,
201 }
202 }
203 } else {
204 Some(task_not_found_response(
205 &request_id_for_poll,
206 &task_id_for_poll,
207 ))
208 };
209
210 if response.is_some() {
211 if detach_on_user_message {
212 ctx.bash_background()
213 .end_wait_mode_session(&session_id_for_cleanup, &task_id_for_poll);
214 } else {
215 ctx.bash_background()
216 .unregister_foreground_task(&session_id_for_cleanup, &task_id_for_poll);
217 }
218 }
219 response
220 });
221
222 if let Some(response) = poll(ctx) {
223 return DispatchOutcome::Immediate(response);
224 }
225
226 DispatchOutcome::Deferred(PendingResponse {
227 request_id,
228 session_id,
229 attach_command,
230 poll,
231 cancellation: None,
232 on_shutdown: None,
233 })
234}
235
236pub(crate) fn poll_bash_status(
237 ctx: &AppContext,
238 task_id: &str,
239 session_id: &str,
240 project_root: Option<&Path>,
241 storage_dir: &Path,
242 preview_bytes: usize,
243) -> Option<BgTaskSnapshot> {
244 ctx.bash_background().status(
245 task_id,
246 session_id,
247 project_root,
248 Some(storage_dir),
249 preview_bytes,
250 )
251}
252
253pub(crate) enum BashStep {
254 Done(Response),
255 Promote,
256 Wait,
257}
258
259pub(crate) fn task_not_found_response(request_id: &str, task_id: &str) -> Response {
260 Response::error(
261 request_id,
262 "task_not_found",
263 crate::commands::bash_status::format_unknown_task_message(task_id),
264 )
265}
266
267pub(crate) fn decide_bash_step(
268 snapshot: BgTaskSnapshot,
269 deadline: Instant,
270 block_to_completion: bool,
271 now: Instant,
272 request_id: &str,
273) -> BashStep {
274 if snapshot.info.status.is_terminal() {
275 BashStep::Done(foreground_result_response(request_id, snapshot))
276 } else if !block_to_completion && now >= deadline {
277 BashStep::Promote
278 } else {
279 BashStep::Wait
280 }
281}
282
283pub(crate) fn promote_bash(
284 ctx: &AppContext,
285 task_id: &str,
286 session_id: &str,
287 timeout: Option<u64>,
288 wait_window_ms: u64,
289 request_id: &str,
290) -> Response {
291 match ctx.bash_background().promote(task_id, session_id) {
292 Ok(_) => promotion_response(request_id, task_id, timeout, wait_window_ms),
293 Err(message) if message.contains("not found") => Response::error(
294 request_id,
295 "task_not_found",
296 crate::commands::bash_status::format_unknown_task_message(task_id),
297 ),
298 Err(message) => Response::error(request_id, "execution_failed", message),
299 }
300}
301
302pub(crate) fn detach_wait_mode_bash(
303 ctx: &AppContext,
304 task_id: &str,
305 session_id: &str,
306 request_id: &str,
307) -> Response {
308 match ctx.bash_background().promote(task_id, session_id) {
309 Ok(_) => wait_detach_response(request_id, task_id),
310 Err(message) if message.contains("not found") => Response::error(
311 request_id,
312 "task_not_found",
313 crate::commands::bash_status::format_unknown_task_message(task_id),
314 ),
315 Err(message) => Response::error(request_id, "execution_failed", message),
316 }
317}
318
319fn foreground_result_response(request_id: &str, snapshot: BgTaskSnapshot) -> Response {
320 let (output, foreground_envelope) =
321 if let Some(envelope) = snapshot.bash_output_list_envelope.as_ref() {
322 let trailer = crate::list_surfaces::bash::envelope_trailer(envelope);
323 let mut foreground_snapshot = snapshot.clone();
324 foreground_snapshot.output_preview = foreground_snapshot
325 .output_preview
326 .strip_suffix(&trailer)
327 .unwrap_or(&foreground_snapshot.output_preview)
328 .trim_end_matches('\n')
329 .to_string();
330 foreground_snapshot.output_truncated = false;
333 let mut output = format_foreground_result(&foreground_snapshot);
334 let envelope = crate::list_surfaces::bash::append_envelope_trailer(
335 &mut output,
336 envelope.total.value(),
337 );
338 (output, envelope)
339 } else {
340 (format_foreground_result(&snapshot), None)
341 };
342 if snapshot.sandbox_native
343 && snapshot.sandbox_unavailable
344 && snapshot.exit_code == Some(crate::sandbox_spawn::SANDBOX_UNAVAILABLE_EXIT_CODE)
345 {
346 return Response::error_with_data(
347 request_id,
348 "sandbox_unavailable",
349 "native sandbox failed before the command could run; set sandbox.enabled=false to disable native sandboxing",
350 json!({
351 "task_id": snapshot.info.task_id,
352 "exit_code": snapshot.exit_code,
353 "output": output,
354 }),
355 );
356 }
357 let timed_out = snapshot.info.status == BgTaskStatus::TimedOut;
358 let mut data = json!({
359 "output": output,
360 "task_id": snapshot.info.task_id,
361 "status": snapshot.info.status,
362 "mode": snapshot.info.mode,
363 "exit_code": snapshot.exit_code,
364 "output_preview": snapshot.output_preview,
365 "output_truncated": snapshot.output_truncated,
366 "truncated": snapshot.output_truncated,
367 "output_path": snapshot.output_path,
368 "timed_out": timed_out,
369 "duration_ms": snapshot.info.duration_ms,
370 });
371 crate::list_surfaces::bash::attach_bash_output_envelope(
372 data.as_object_mut()
373 .expect("foreground bash data is an object"),
374 &foreground_envelope,
375 );
376 Response::success(request_id, data)
377}
378
379fn background_launch_response(request_id: &str, task_id: &str, is_pty: bool) -> Response {
380 Response::success(
381 request_id,
382 json!({
383 "output": format_background_launch(task_id, is_pty),
384 "task_id": task_id,
385 "status": "running",
386 "mode": if is_pty { "pty" } else { "pipes" },
387 }),
388 )
389}
390
391fn promotion_response(
392 request_id: &str,
393 task_id: &str,
394 timeout: Option<u64>,
395 wait_window_ms: u64,
396) -> Response {
397 Response::success(
398 request_id,
399 json!({
400 "output": format_promotion_message(task_id, timeout, wait_window_ms),
401 "task_id": task_id,
402 "status": "running",
403 }),
404 )
405}
406
407fn wait_detach_response(request_id: &str, task_id: &str) -> Response {
408 Response::success(
409 request_id,
410 json!({
411 "output": format_wait_detach_message(task_id),
412 "task_id": task_id,
413 "status": "running",
414 }),
415 )
416}
417
418fn parse_params(req: &RawRequest) -> Option<BashOrchestrateParams> {
419 let raw_params = req
420 .params
421 .get("params")
422 .cloned()
423 .unwrap_or_else(|| req.params.clone());
424 serde_json::from_value::<BashOrchestrateParams>(raw_params).ok()
425}
426
427pub(crate) fn resolve_foreground_wait_window_ms(configured: u64) -> u64 {
428 std::env::var(TEST_FOREGROUND_WAIT_ENV)
429 .ok()
430 .and_then(|raw| raw.parse::<u64>().ok())
431 .unwrap_or(configured)
432}
433
434pub(crate) fn select_foreground_wait_window_ms(
435 configured: u64,
436 timeout: Option<u64>,
437 wait: bool,
438) -> u64 {
439 if wait {
440 timeout.unwrap_or(DEFAULT_FOREGROUND_WAIT_TIMEOUT_MS)
441 } else {
442 resolve_foreground_wait_window_ms(configured)
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::bash_background::persistence::BgMode;
450 use crate::bash_background::registry::BgTaskSnapshot;
451 use crate::bash_background::BgTaskInfo;
452
453 fn snapshot(
454 output_preview: &str,
455 output_truncated: bool,
456 output_path: Option<&str>,
457 status: BgTaskStatus,
458 exit_code: Option<i32>,
459 ) -> BgTaskSnapshot {
460 BgTaskSnapshot {
461 info: BgTaskInfo {
462 task_id: "bash-test".to_string(),
463 status,
464 command: "echo test".to_string(),
465 mode: BgMode::Pipes,
466 started_at: 0,
467 duration_ms: Some(1),
468 status_reason: None,
469 },
470 exit_code,
471 child_pid: None,
472 workdir: "/tmp".to_string(),
473 output_preview: output_preview.to_string(),
474 bash_output_list_envelope: None,
475 output_truncated,
476 output_path: output_path.map(str::to_string),
477 stderr_path: None,
478 pty_rows: None,
479 pty_cols: None,
480 pty_screen: None,
481 scanner_report: Vec::new(),
482 sandbox_native: false,
483 sandbox_unavailable: false,
484 live_descendants: Some(Vec::new()),
485 live_descendants_omitted: 0,
486 live_descendants_summary: None,
487 kill_signaled: false,
488 kill_reached: 0,
489 }
490 }
491
492 #[test]
493 fn native_launcher_exit_78_is_a_structured_sandbox_error() {
494 let mut snapshot = snapshot(
495 "sandbox_unavailable: backend failed",
496 false,
497 None,
498 BgTaskStatus::Failed,
499 Some(crate::sandbox_spawn::SANDBOX_UNAVAILABLE_EXIT_CODE),
500 );
501 snapshot.sandbox_native = true;
502 snapshot.sandbox_unavailable = true;
503
504 let response = foreground_result_response("sandbox-failed", snapshot);
505 assert!(!response.success);
506 assert_eq!(
507 response
508 .data
509 .get("code")
510 .and_then(serde_json::Value::as_str),
511 Some("sandbox_unavailable")
512 );
513 assert!(response
514 .data
515 .get("message")
516 .and_then(serde_json::Value::as_str)
517 .is_some_and(|message| message.contains("sandbox.enabled=false")));
518 }
519
520 #[test]
521 fn native_command_exit_78_is_not_misreported_as_launcher_failure() {
522 let mut snapshot = snapshot(
523 "command selected exit 78",
524 false,
525 None,
526 BgTaskStatus::Failed,
527 Some(crate::sandbox_spawn::SANDBOX_UNAVAILABLE_EXIT_CODE),
528 );
529 snapshot.sandbox_native = true;
530
531 let response = foreground_result_response("command-exit-78", snapshot);
532 assert!(response.success);
533 assert_eq!(
534 response
535 .data
536 .get("exit_code")
537 .and_then(serde_json::Value::as_i64),
538 Some(i64::from(
539 crate::sandbox_spawn::SANDBOX_UNAVAILABLE_EXIT_CODE
540 ))
541 );
542 }
543
544 #[test]
545 fn decide_bash_step_returns_done_for_terminal_snapshot_even_at_deadline() {
546 let snapshot = snapshot("done", false, None, BgTaskStatus::Completed, Some(0));
547 let now = Instant::now();
548
549 match decide_bash_step(snapshot, now, false, now, "req-terminal") {
550 BashStep::Done(response) => {
551 assert_eq!(response.id, "req-terminal");
552 assert!(response.success);
553 assert_eq!(response.data["status"], json!("completed"));
554 assert_eq!(response.data["output"], json!("done"));
555 }
556 BashStep::Promote => panic!("terminal snapshot should not promote"),
557 BashStep::Wait => panic!("terminal snapshot should not wait"),
558 }
559 }
560
561 #[test]
562 fn decide_bash_step_promotes_at_deadline_when_not_blocking() {
563 let snapshot = snapshot("running", false, None, BgTaskStatus::Running, None);
564 let now = Instant::now();
565
566 match decide_bash_step(snapshot, now, false, now, "req-promote") {
567 BashStep::Promote => {}
568 BashStep::Done(_) => panic!("running snapshot should not finish"),
569 BashStep::Wait => panic!("deadline should promote when not blocking"),
570 }
571 }
572
573 #[test]
574 fn decide_bash_step_waits_before_deadline() {
575 let snapshot = snapshot("running", false, None, BgTaskStatus::Running, None);
576 let now = Instant::now();
577
578 match decide_bash_step(
579 snapshot,
580 now + Duration::from_millis(1),
581 false,
582 now,
583 "req-wait",
584 ) {
585 BashStep::Wait => {}
586 BashStep::Done(_) => panic!("running snapshot should not finish"),
587 BashStep::Promote => panic!("snapshot should wait before the deadline"),
588 }
589 }
590
591 #[test]
592 fn decide_bash_step_never_promotes_when_blocking_to_completion() {
593 let snapshot = snapshot("running", false, None, BgTaskStatus::Running, None);
594 let now = Instant::now();
595
596 match decide_bash_step(snapshot, now, true, now, "req-block") {
597 BashStep::Wait => {}
598 BashStep::Done(_) => panic!("running snapshot should not finish"),
599 BashStep::Promote => panic!("block_to_completion should suppress promotion"),
600 }
601 }
602
603 #[test]
604 fn select_foreground_wait_window_uses_timeout_budget_for_wait_true() {
605 assert_eq!(
606 select_foreground_wait_window_ms(8_000, Some(250), true),
607 250
608 );
609 assert_eq!(
610 select_foreground_wait_window_ms(8_000, None, true),
611 DEFAULT_FOREGROUND_WAIT_TIMEOUT_MS
612 );
613 }
614
615 #[test]
616 fn foreground_result_format_matches_typescript_order() {
617 let snapshot = snapshot(
618 "hello",
619 true,
620 Some("/tmp/aft-output.txt"),
621 BgTaskStatus::TimedOut,
622 Some(124),
623 );
624
625 assert_eq!(
626 format_foreground_result(&snapshot),
627 "hello\n[output truncated; full output at /tmp/aft-output.txt]\n[command timed out]\n[exit code: 124]"
628 );
629 }
630
631 #[test]
632 fn format_seconds_strips_integer_decimal_and_keeps_tenths() {
633 assert_eq!(format_seconds(8_000), "8s");
634 assert_eq!(format_seconds(5_500), "5.5s");
635 assert_eq!(format_seconds(14_999), "15s");
636 }
637
638 #[test]
639 fn promotion_message_matches_opencode_copy() {
640 assert_eq!(
641 format_promotion_message("bash-123", Some(5_500), 8_000),
642 "Foreground bash didn't finish within 5.5s and was promoted to background: bash-123. A completion reminder will be delivered automatically; use bash_status({ taskId: \"bash-123\" }) to inspect output or bash_kill({ taskId: \"bash-123\" }) to terminate."
643 );
644 }
645
646 #[test]
647 fn wait_detach_message_mentions_user_message() {
648 assert_eq!(
649 format_wait_detach_message("bash-123"),
650 "Foreground bash is running in background as bash-123. A completion reminder will be delivered automatically; use bash_status({ taskId: \"bash-123\" }) to inspect output or bash_kill({ taskId: \"bash-123\" }) to terminate.\nDetached because a user message arrived."
651 );
652 }
653
654 #[test]
655 fn background_launch_messages_match_opencode_copy() {
656 assert_eq!(
657 format_background_launch("bash-bg", false),
658 "Background task started: bash-bg. A completion reminder will be delivered automatically; don't poll bash_status."
659 );
660 assert_eq!(
661 format_background_launch("bash-pty", true),
662 "PTY task started: bash-pty. Use bash_status({ taskId: \"bash-pty\", outputMode: \"screen\" }) to see the visible terminal, bash_write({ taskId: \"bash-pty\", input: ... }) to send keystrokes. A completion reminder fires automatically when the task exits."
663 );
664 }
665}