Skip to main content

aft/commands/
bash_orchestrate.rs

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
27/// Port of `packages/aft-bridge/src/bash-format.ts` `formatForegroundResult` (lines 8-25).
28pub 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
46/// Port of `packages/aft-bridge/src/bash-format.ts` `formatSeconds` (lines 3-6).
47pub 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
61/// Port of OpenCode `packages/opencode-plugin/src/tools/bash.ts` `formatPromotionMessage` (lines 603-614).
62pub 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
84/// Port of OpenCode `packages/opencode-plugin/src/tools/bash.ts` `formatBackgroundLaunch` (lines 593-601).
85pub 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    if detach_on_user_message {
139        ctx.bash_background().begin_wait_mode_session(&session_id);
140    }
141    let wait_window_ms = select_foreground_wait_window_ms(
142        ctx.config().foreground_wait_window_ms,
143        params.timeout,
144        params.wait,
145    );
146    let deadline = Instant::now() + Duration::from_millis(wait_window_ms);
147    let block_to_completion = params.block_to_completion || params.wait;
148    let timeout = params.timeout;
149    let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
150    let project_root = ctx.config().project_root.clone();
151    let task_id_for_poll = task_id.clone();
152    let request_id_for_poll = request_id.clone();
153    let session_id_for_poll = session_id.clone();
154    let session_id_for_cleanup = session_id.clone();
155
156    let mut poll: PendingResponsePoll = Box::new(move |ctx| {
157        // Foreground polls only need task state. Terminal snapshots still
158        // return cached output.
159        let response = if let Some(snapshot) = poll_bash_status(
160            ctx,
161            &task_id_for_poll,
162            &session_id_for_poll,
163            project_root.as_deref(),
164            &storage_dir,
165            0,
166        ) {
167            if snapshot.info.status.is_terminal() {
168                Some(foreground_result_response(&request_id_for_poll, snapshot))
169            } else if detach_on_user_message
170                && ctx
171                    .bash_background()
172                    .take_wait_mode_detach(&session_id_for_poll)
173            {
174                Some(detach_wait_mode_bash(
175                    ctx,
176                    &task_id_for_poll,
177                    &session_id_for_poll,
178                    &request_id_for_poll,
179                ))
180            } else {
181                match decide_bash_step(
182                    snapshot,
183                    deadline,
184                    block_to_completion,
185                    Instant::now(),
186                    &request_id_for_poll,
187                ) {
188                    BashStep::Done(response) => Some(response),
189                    BashStep::Promote => Some(promote_bash(
190                        ctx,
191                        &task_id_for_poll,
192                        &session_id_for_poll,
193                        timeout,
194                        wait_window_ms,
195                        &request_id_for_poll,
196                    )),
197                    BashStep::Wait => None,
198                }
199            }
200        } else {
201            Some(task_not_found_response(
202                &request_id_for_poll,
203                &task_id_for_poll,
204            ))
205        };
206
207        if response.is_some() && detach_on_user_message {
208            ctx.bash_background()
209                .end_wait_mode_session(&session_id_for_cleanup);
210        }
211        response
212    });
213
214    if let Some(response) = poll(ctx) {
215        return DispatchOutcome::Immediate(response);
216    }
217
218    DispatchOutcome::Deferred(PendingResponse {
219        request_id,
220        session_id,
221        attach_command,
222        poll,
223    })
224}
225
226pub(crate) fn poll_bash_status(
227    ctx: &AppContext,
228    task_id: &str,
229    session_id: &str,
230    project_root: Option<&Path>,
231    storage_dir: &Path,
232    preview_bytes: usize,
233) -> Option<BgTaskSnapshot> {
234    ctx.bash_background().status(
235        task_id,
236        session_id,
237        project_root,
238        Some(storage_dir),
239        preview_bytes,
240    )
241}
242
243pub(crate) enum BashStep {
244    Done(Response),
245    Promote,
246    Wait,
247}
248
249pub(crate) fn task_not_found_response(request_id: &str, task_id: &str) -> Response {
250    Response::error(
251        request_id,
252        "task_not_found",
253        format!("background task not found: {task_id}"),
254    )
255}
256
257pub(crate) fn decide_bash_step(
258    snapshot: BgTaskSnapshot,
259    deadline: Instant,
260    block_to_completion: bool,
261    now: Instant,
262    request_id: &str,
263) -> BashStep {
264    if snapshot.info.status.is_terminal() {
265        BashStep::Done(foreground_result_response(request_id, snapshot))
266    } else if !block_to_completion && now >= deadline {
267        BashStep::Promote
268    } else {
269        BashStep::Wait
270    }
271}
272
273pub(crate) fn promote_bash(
274    ctx: &AppContext,
275    task_id: &str,
276    session_id: &str,
277    timeout: Option<u64>,
278    wait_window_ms: u64,
279    request_id: &str,
280) -> Response {
281    match ctx.bash_background().promote(task_id, session_id) {
282        Ok(_) => promotion_response(request_id, task_id, timeout, wait_window_ms),
283        Err(message) if message.contains("not found") => {
284            Response::error(request_id, "task_not_found", message)
285        }
286        Err(message) => Response::error(request_id, "execution_failed", message),
287    }
288}
289
290pub(crate) fn detach_wait_mode_bash(
291    ctx: &AppContext,
292    task_id: &str,
293    session_id: &str,
294    request_id: &str,
295) -> Response {
296    match ctx.bash_background().promote(task_id, session_id) {
297        Ok(_) => wait_detach_response(request_id, task_id),
298        Err(message) if message.contains("not found") => {
299            Response::error(request_id, "task_not_found", message)
300        }
301        Err(message) => Response::error(request_id, "execution_failed", message),
302    }
303}
304
305fn foreground_result_response(request_id: &str, snapshot: BgTaskSnapshot) -> Response {
306    let output = format_foreground_result(&snapshot);
307    let timed_out = snapshot.info.status == BgTaskStatus::TimedOut;
308    Response::success(
309        request_id,
310        json!({
311            "output": output,
312            "task_id": snapshot.info.task_id,
313            "status": snapshot.info.status,
314            "mode": snapshot.info.mode,
315            "exit_code": snapshot.exit_code,
316            "output_preview": snapshot.output_preview,
317            "output_truncated": snapshot.output_truncated,
318            "truncated": snapshot.output_truncated,
319            "output_path": snapshot.output_path,
320            "timed_out": timed_out,
321            "duration_ms": snapshot.info.duration_ms,
322        }),
323    )
324}
325
326fn background_launch_response(request_id: &str, task_id: &str, is_pty: bool) -> Response {
327    Response::success(
328        request_id,
329        json!({
330            "output": format_background_launch(task_id, is_pty),
331            "task_id": task_id,
332            "status": "running",
333            "mode": if is_pty { "pty" } else { "pipes" },
334        }),
335    )
336}
337
338fn promotion_response(
339    request_id: &str,
340    task_id: &str,
341    timeout: Option<u64>,
342    wait_window_ms: u64,
343) -> Response {
344    Response::success(
345        request_id,
346        json!({
347            "output": format_promotion_message(task_id, timeout, wait_window_ms),
348            "task_id": task_id,
349            "status": "running",
350        }),
351    )
352}
353
354fn wait_detach_response(request_id: &str, task_id: &str) -> Response {
355    Response::success(
356        request_id,
357        json!({
358            "output": format_wait_detach_message(task_id),
359            "task_id": task_id,
360            "status": "running",
361        }),
362    )
363}
364
365fn parse_params(req: &RawRequest) -> Option<BashOrchestrateParams> {
366    let raw_params = req
367        .params
368        .get("params")
369        .cloned()
370        .unwrap_or_else(|| req.params.clone());
371    serde_json::from_value::<BashOrchestrateParams>(raw_params).ok()
372}
373
374pub(crate) fn resolve_foreground_wait_window_ms(configured: u64) -> u64 {
375    std::env::var(TEST_FOREGROUND_WAIT_ENV)
376        .ok()
377        .and_then(|raw| raw.parse::<u64>().ok())
378        .unwrap_or(configured)
379}
380
381pub(crate) fn select_foreground_wait_window_ms(
382    configured: u64,
383    timeout: Option<u64>,
384    wait: bool,
385) -> u64 {
386    if wait {
387        timeout.unwrap_or(DEFAULT_FOREGROUND_WAIT_TIMEOUT_MS)
388    } else {
389        resolve_foreground_wait_window_ms(configured)
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::bash_background::persistence::BgMode;
397    use crate::bash_background::registry::BgTaskSnapshot;
398    use crate::bash_background::BgTaskInfo;
399
400    fn snapshot(
401        output_preview: &str,
402        output_truncated: bool,
403        output_path: Option<&str>,
404        status: BgTaskStatus,
405        exit_code: Option<i32>,
406    ) -> BgTaskSnapshot {
407        BgTaskSnapshot {
408            info: BgTaskInfo {
409                task_id: "bash-test".to_string(),
410                status,
411                command: "echo test".to_string(),
412                mode: BgMode::Pipes,
413                started_at: 0,
414                duration_ms: Some(1),
415            },
416            exit_code,
417            child_pid: None,
418            workdir: "/tmp".to_string(),
419            output_preview: output_preview.to_string(),
420            output_truncated,
421            output_path: output_path.map(str::to_string),
422            stderr_path: None,
423            pty_rows: None,
424            pty_cols: None,
425            pty_screen: None,
426        }
427    }
428
429    #[test]
430    fn decide_bash_step_returns_done_for_terminal_snapshot_even_at_deadline() {
431        let snapshot = snapshot("done", false, None, BgTaskStatus::Completed, Some(0));
432        let now = Instant::now();
433
434        match decide_bash_step(snapshot, now, false, now, "req-terminal") {
435            BashStep::Done(response) => {
436                assert_eq!(response.id, "req-terminal");
437                assert!(response.success);
438                assert_eq!(response.data["status"], json!("completed"));
439                assert_eq!(response.data["output"], json!("done"));
440            }
441            BashStep::Promote => panic!("terminal snapshot should not promote"),
442            BashStep::Wait => panic!("terminal snapshot should not wait"),
443        }
444    }
445
446    #[test]
447    fn decide_bash_step_promotes_at_deadline_when_not_blocking() {
448        let snapshot = snapshot("running", false, None, BgTaskStatus::Running, None);
449        let now = Instant::now();
450
451        match decide_bash_step(snapshot, now, false, now, "req-promote") {
452            BashStep::Promote => {}
453            BashStep::Done(_) => panic!("running snapshot should not finish"),
454            BashStep::Wait => panic!("deadline should promote when not blocking"),
455        }
456    }
457
458    #[test]
459    fn decide_bash_step_waits_before_deadline() {
460        let snapshot = snapshot("running", false, None, BgTaskStatus::Running, None);
461        let now = Instant::now();
462
463        match decide_bash_step(
464            snapshot,
465            now + Duration::from_millis(1),
466            false,
467            now,
468            "req-wait",
469        ) {
470            BashStep::Wait => {}
471            BashStep::Done(_) => panic!("running snapshot should not finish"),
472            BashStep::Promote => panic!("snapshot should wait before the deadline"),
473        }
474    }
475
476    #[test]
477    fn decide_bash_step_never_promotes_when_blocking_to_completion() {
478        let snapshot = snapshot("running", false, None, BgTaskStatus::Running, None);
479        let now = Instant::now();
480
481        match decide_bash_step(snapshot, now, true, now, "req-block") {
482            BashStep::Wait => {}
483            BashStep::Done(_) => panic!("running snapshot should not finish"),
484            BashStep::Promote => panic!("block_to_completion should suppress promotion"),
485        }
486    }
487
488    #[test]
489    fn select_foreground_wait_window_uses_timeout_budget_for_wait_true() {
490        assert_eq!(
491            select_foreground_wait_window_ms(8_000, Some(250), true),
492            250
493        );
494        assert_eq!(
495            select_foreground_wait_window_ms(8_000, None, true),
496            DEFAULT_FOREGROUND_WAIT_TIMEOUT_MS
497        );
498    }
499
500    #[test]
501    fn foreground_result_format_matches_typescript_order() {
502        let snapshot = snapshot(
503            "hello",
504            true,
505            Some("/tmp/aft-output.txt"),
506            BgTaskStatus::TimedOut,
507            Some(124),
508        );
509
510        assert_eq!(
511            format_foreground_result(&snapshot),
512            "hello\n[output truncated; full output at /tmp/aft-output.txt]\n[command timed out]\n[exit code: 124]"
513        );
514    }
515
516    #[test]
517    fn format_seconds_strips_integer_decimal_and_keeps_tenths() {
518        assert_eq!(format_seconds(8_000), "8s");
519        assert_eq!(format_seconds(5_500), "5.5s");
520        assert_eq!(format_seconds(14_999), "15s");
521    }
522
523    #[test]
524    fn promotion_message_matches_opencode_copy() {
525        assert_eq!(
526            format_promotion_message("bash-123", Some(5_500), 8_000),
527            "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."
528        );
529    }
530
531    #[test]
532    fn wait_detach_message_mentions_user_message() {
533        assert_eq!(
534            format_wait_detach_message("bash-123"),
535            "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."
536        );
537    }
538
539    #[test]
540    fn background_launch_messages_match_opencode_copy() {
541        assert_eq!(
542            format_background_launch("bash-bg", false),
543            "Background task started: bash-bg. A completion reminder will be delivered automatically; don't poll bash_status."
544        );
545        assert_eq!(
546            format_background_launch("bash-pty", true),
547            "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."
548        );
549    }
550}