Skip to main content

aft/
response_finalize.rs

1#[path = "alert_render.rs"]
2pub mod alert_render;
3#[path = "repeat_breaker.rs"]
4pub mod repeat_breaker;
5
6use std::path::Path;
7
8use crate::context::AppContext;
9use crate::protocol::Response;
10
11pub fn append_repeat_breaker_reminder(
12    text: &mut String,
13    session_id: &str,
14    intervention: &repeat_breaker::RepeatIntervention,
15) {
16    let count = intervention.count;
17    let span_seconds = intervention.span.as_secs();
18    let ordinal = ordinal(count);
19    // Two wording rules from the fleet review. The detector measured only that
20    // the command and its output are unchanged, so the text says that and not
21    // that the world is static (a CI run can move from queued to running with
22    // identical `gh run view` output). And a worker that backgrounded a gate
23    // and polls it in bounded watch windows produces exactly this signature
24    // while doing the right thing, so the instruction names the correct next
25    // action (let the watch return) rather than a prohibition it would have to
26    // decide to disobey.
27    let instruction = if repeat_breaker::escalation_starts_at(count) {
28        "The turn must end now with no further tool call. If you are waiting on a task or CI run, use a background task with a watch (or the background handle you already hold) and end the turn. If you are already watching, let the watch return before calling again."
29    } else {
30        "If you are waiting on a task or CI run, use a background task with a watch (or the background handle you already hold) and end the turn. If you are already watching, let the watch return before calling again."
31    };
32    let reminder = format!(
33        "<system-reminder>\nThis is the {ordinal} identical call (same command, same output) in {span_seconds}s. This call is not returning anything new. {instruction}\n</system-reminder>"
34    );
35    if text.is_empty() {
36        *text = reminder;
37    } else {
38        text.push_str("\n\n");
39        text.push_str(&reminder);
40    }
41    log::info!(
42        "repeat_breaker fired session={} tool={} count={} span_ms={}",
43        session_id,
44        intervention.tool,
45        count,
46        intervention.span.as_millis()
47    );
48}
49
50fn ordinal(count: u64) -> String {
51    let suffix = if (11..=13).contains(&(count % 100)) {
52        "th"
53    } else {
54        match count % 10 {
55            1 => "st",
56            2 => "nd",
57            3 => "rd",
58            _ => "th",
59        }
60    };
61    format!("{count}{suffix}")
62}
63
64/// Finalize a direct protocol response that has no dispatch-root provenance. Agent-visible
65/// finalization must use [`finalize_response_for_dispatch_root`] so alert delivery never infers
66/// a root from the session context.
67pub fn finalize_response(
68    response: &mut Response,
69    ctx: &AppContext,
70    session_id: &str,
71    attach_command: &str,
72) {
73    finalize_response_with_bg_completions(response, ctx, session_id, attach_command, true);
74}
75
76/// Compatibility finalization for direct protocol responses without explicit dispatch-root
77/// provenance. Agent-visible responses use [`finalize_response_for_dispatch_root`].
78pub fn finalize_response_with_bg_completions(
79    response: &mut Response,
80    ctx: &AppContext,
81    session_id: &str,
82    attach_command: &str,
83    allow_bg_completions: bool,
84) {
85    if allow_bg_completions {
86        attach_bg_completions(response, ctx, session_id, attach_command);
87    }
88    let plane_live = publish_fleet_status(response, ctx, session_id);
89
90    // The pre-tool-call protocol has no dispatch-root provenance or agent-visible text. Keep its
91    // legacy envelope seam isolated from terminal agent responses while older direct fixtures
92    // migrate to explicit-root finalization.
93    if response.data.get("text").is_none()
94        && !alert_render::is_excluded_finalization_command(attach_command)
95    {
96        attach_status_bar_after_publish(response, ctx, plane_live);
97    }
98}
99
100/// Finalize an agent-visible response using the root selected by dispatch. The finalizer owns
101/// the alert transition and never reads `ctx.config().project_root` for alert state.
102pub fn finalize_response_for_dispatch_root(
103    response: &mut Response,
104    ctx: &AppContext,
105    alerts: &mut alert_render::AlertEngine,
106    session_id: &str,
107    dispatch_root: &Path,
108    attach_command: &str,
109    allow_bg_completions: bool,
110) {
111    if allow_bg_completions {
112        attach_bg_completions(response, ctx, session_id, attach_command);
113    }
114    let _ = publish_fleet_status(response, ctx, session_id);
115    attach_alert_block(response, alerts, session_id, dispatch_root, attach_command);
116}
117
118fn attach_alert_block(
119    response: &mut Response,
120    alerts: &mut alert_render::AlertEngine,
121    session_id: &str,
122    dispatch_root: &Path,
123    command: &str,
124) {
125    let Some(text) = response
126        .data
127        .as_object_mut()
128        .and_then(|data| data.get_mut("text"))
129        .and_then(|value| value.as_str())
130        .map(str::to_string)
131    else {
132        return;
133    };
134
135    // A response can pass through a structured transport as well as its terminal adapter.
136    // Refuse a second server reminder rather than consuming an alert behind a duplicate block.
137    if text.contains("<system-reminder>") {
138        return;
139    }
140    let Some(alert) = alerts.finalize(session_id, dispatch_root, command) else {
141        return;
142    };
143    let joined = if text.is_empty() {
144        alert.text
145    } else {
146        format!("{text}\n\n{}", alert.text)
147    };
148    if let Some(data) = response.data.as_object_mut() {
149        data.insert("text".to_string(), serde_json::Value::String(joined));
150    }
151}
152
153pub enum DispatchOutcome {
154    Immediate(Response),
155    Deferred(PendingResponse),
156}
157
158pub type PendingResponsePoll = Box<dyn FnMut(&AppContext) -> Option<Response> + Send>;
159pub type PendingResponseShutdown = Box<dyn FnMut(&AppContext) -> Response + Send>;
160
161pub struct PendingResponse {
162    pub request_id: String,
163    pub session_id: String,
164    pub attach_command: String,
165    pub poll: PendingResponsePoll,
166    /// Cancellation shared with work that continued after its executor setup
167    /// job returned. Registry replacement and transport shutdown signal it
168    /// before removing the pending entry.
169    pub cancellation: Option<crate::executor::JobCancellation>,
170    /// Optional terminal response emitted before this entry is removed during
171    /// shutdown. Long-running inspect uses this to avoid silently dropping its
172    /// only agent-visible terminal frame.
173    pub on_shutdown: Option<PendingResponseShutdown>,
174}
175
176pub struct ResolvedPending {
177    pub response: Response,
178    pub session_id: String,
179    pub attach_command: String,
180}
181
182#[derive(Default)]
183pub struct PendingResponses {
184    entries: Vec<PendingResponse>,
185}
186
187impl PendingResponses {
188    pub fn register(&mut self, pending: PendingResponse) {
189        self.entries.retain(|entry| {
190            let keep = entry.request_id != pending.request_id;
191            if !keep {
192                if let Some(cancellation) = &entry.cancellation {
193                    cancellation.request_cancel();
194                }
195            }
196            keep
197        });
198        self.entries.push(pending);
199    }
200
201    /// Signal cooperative cancellation without removing the response slot.
202    /// The worker owns the terminal response and resolves it through `poll_ready`.
203    pub fn cancel_request(&mut self, request_id: &str) -> bool {
204        let Some(entry) = self
205            .entries
206            .iter()
207            .find(|entry| entry.request_id == request_id)
208        else {
209            return false;
210        };
211        let Some(cancellation) = &entry.cancellation else {
212            return false;
213        };
214        cancellation.request_cancel();
215        true
216    }
217
218    pub fn poll_ready(&mut self, ctx: &AppContext) -> Vec<ResolvedPending> {
219        let mut ready = Vec::new();
220        let mut waiting = Vec::with_capacity(self.entries.len());
221
222        for mut pending in self.entries.drain(..) {
223            if let Some(response) = (pending.poll)(ctx) {
224                ready.push(ResolvedPending {
225                    response,
226                    session_id: pending.session_id,
227                    attach_command: pending.attach_command,
228                });
229            } else {
230                waiting.push(pending);
231            }
232        }
233
234        self.entries = waiting;
235        ready
236    }
237
238    pub fn is_empty(&self) -> bool {
239        self.entries.is_empty()
240    }
241
242    pub fn drain_on_shutdown(&mut self) {
243        for pending in self.entries.drain(..) {
244            if let Some(cancellation) = &pending.cancellation {
245                cancellation.request_cancel();
246            }
247        }
248    }
249
250    /// Resolve shutdown-aware entries before removing them from the registry.
251    /// Entries without a shutdown terminal retain the legacy drop behavior.
252    pub fn drain_on_shutdown_with(&mut self, ctx: &AppContext) -> Vec<ResolvedPending> {
253        self.entries
254            .drain(..)
255            .filter_map(|mut pending| {
256                if let Some(cancellation) = &pending.cancellation {
257                    cancellation.request_cancel();
258                }
259                let response = (pending.on_shutdown.as_mut()?)(ctx);
260                Some(ResolvedPending {
261                    response,
262                    session_id: pending.session_id,
263                    attach_command: pending.attach_command,
264                })
265            })
266            .collect()
267    }
268}
269
270pub fn attach_bg_completions(
271    response: &mut Response,
272    ctx: &AppContext,
273    session_id: &str,
274    command: &str,
275) {
276    if matches!(
277        command,
278        "configure"
279            | "bash_abort_inflight"
280            | "bash_status"
281            | "bash_write"
282            | "bash_promote"
283            | "bash_wait_detach"
284            | "bash_regex_match"
285            | "bash_drain_completions"
286            | "bash_notify"
287            | "bash_unnotify"
288            | "bash_ack_completions"
289    ) {
290        return;
291    }
292    if !ctx
293        .bash_background()
294        .has_completions_for_session(Some(session_id))
295    {
296        return;
297    }
298    let completions = ctx
299        .bash_background()
300        .drain_completions_for_session(Some(session_id));
301    if completions.is_empty() {
302        return;
303    }
304    let value = serde_json::json!(completions);
305    match response.data.as_object_mut() {
306        Some(data) => {
307            data.insert("bg_completions".to_string(), value);
308        }
309        None => {
310            response.data = serde_json::json!({ "bg_completions": value });
311        }
312    }
313}
314
315fn aft_status_segment(counts: &crate::context::StatusBarCounts) -> String {
316    let stale_mark = if counts.tier2_stale { "~" } else { "" };
317    // Self-labeled per the fleet status-line format ruling (2026-08-17): the
318    // holder composes segments label-free and joins module boundaries with a
319    // bullet, so each publisher's text must carry its own leading label.
320    format!(
321        "AFT E{} W{} | {}D{} U{} C{} | T{}",
322        counts.errors,
323        counts.warnings,
324        stale_mark,
325        counts.dead_code,
326        counts.unused_exports,
327        counts.duplicates,
328        counts.todos
329    )
330}
331
332fn holder_owns_status_bar(plane_live: bool, harness: Option<&crate::harness::Harness>) -> bool {
333    plane_live && matches!(harness, Some(crate::harness::Harness::Opencode))
334}
335
336/// Publish the retained fleet status segment. Agent-facing status-bar envelope insertion is
337/// intentionally absent: reminder rendering below is the only agent response finalizer.
338fn publish_fleet_status(
339    response: &mut Response,
340    ctx: &AppContext,
341    session_id: &str,
342) -> Option<bool> {
343    // Cross-root indexed searches currently suppress fleet status. Remove the private marker
344    // before publishing the response so it cannot appear in a response envelope.
345    if response
346        .data
347        .as_object_mut()
348        .and_then(|data| data.remove("_aft_suppress_status_bar"))
349        .is_some()
350    {
351        return None;
352    }
353
354    let local_counts = ctx.status_bar_counts();
355    let harness = ctx.harness_opt();
356    let plane_live = ctx.fleet_status_client().is_some_and(|client| {
357        let config = ctx.config();
358        let Some(project_root) = config.project_root.as_deref() else {
359            return false;
360        };
361        let harness_label = harness
362            .as_ref()
363            .map(crate::harness::Harness::wire_label)
364            .unwrap_or_else(|| "unknown".to_string());
365        let aft_text = local_counts
366            .as_ref()
367            .map(aft_status_segment)
368            .unwrap_or_default();
369        client.publish(project_root, &harness_label, session_id, &aft_text)
370    });
371    Some(plane_live)
372}
373
374/// Retired envelope helper retained for direct legacy test fixtures. Production finalization
375/// calls `publish_fleet_status` and cannot emit this field.
376pub fn attach_status_bar(
377    response: &mut Response,
378    ctx: &AppContext,
379    session_id: &str,
380    command: &str,
381) {
382    if alert_render::is_excluded_finalization_command(command) {
383        return;
384    }
385    let plane_live = publish_fleet_status(response, ctx, session_id);
386    attach_status_bar_after_publish(response, ctx, plane_live);
387}
388
389fn attach_status_bar_after_publish(
390    response: &mut Response,
391    ctx: &AppContext,
392    plane_live: Option<bool>,
393) {
394    let Some(plane_live) = plane_live else {
395        return;
396    };
397    let harness = ctx.harness_opt();
398    if holder_owns_status_bar(plane_live, harness.as_ref()) {
399        return;
400    }
401    let Some(counts) = ctx.status_bar_counts() else {
402        return;
403    };
404    if !ctx.should_emit_status_bar(&counts) {
405        return;
406    }
407    let value = serde_json::json!({
408        "errors": counts.errors,
409        "warnings": counts.warnings,
410        "dead_code": counts.dead_code,
411        "unused_exports": counts.unused_exports,
412        "duplicates": counts.duplicates,
413        "todos": counts.todos,
414        "tier2_stale": counts.tier2_stale,
415    });
416    match response.data.as_object_mut() {
417        Some(data) => {
418            data.insert("status_bar".to_string(), value);
419        }
420        None => {
421            response.data = serde_json::json!({ "status_bar": value });
422        }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use std::path::PathBuf;
429
430    use super::{
431        aft_status_segment, finalize_response_with_bg_completions, holder_owns_status_bar,
432        PendingResponse, PendingResponses,
433    };
434    use crate::config::Config;
435    use crate::context::{AppContext, StatusBarCounts};
436    use crate::fleet_status::FleetStatusClient;
437    use crate::harness::Harness;
438    use crate::parser::TreeSitterProvider;
439    use crate::protocol::Response;
440
441    #[test]
442    fn live_holder_retires_only_opencode_response_bars() {
443        assert_eq!(
444            (
445                holder_owns_status_bar(true, Some(&Harness::Opencode)),
446                holder_owns_status_bar(true, Some(&Harness::Runner)),
447            ),
448            (true, false)
449        );
450        assert!(!holder_owns_status_bar(true, Some(&Harness::Pi)));
451        assert!(!holder_owns_status_bar(false, Some(&Harness::Opencode)));
452    }
453
454    #[test]
455    fn pre_discovery_publish_does_not_trip_the_holder_ownership_gate() {
456        let ctx = AppContext::new(
457            Box::new(TreeSitterProvider::new()),
458            Config {
459                project_root: Some(PathBuf::from("/tmp/project")),
460                ..Config::default()
461            },
462        );
463        ctx.set_harness(Harness::Opencode);
464        ctx.update_status_bar_tier2(Some(21), Some(12), Some(13), Some(14), false);
465        let (client, mut wire_rx) = FleetStatusClient::dial_channel(1);
466        ctx.install_fleet_status_client(Some(client));
467        let mut response = Response::success("status", serde_json::json!({}));
468
469        finalize_response_with_bg_completions(&mut response, &ctx, "session-1", "echo", false);
470
471        assert!(response.data.get("status_bar").is_none());
472        let publish = wire_rx.try_recv().expect("single discovery publish");
473        assert_eq!(
474            publish.body()["text"],
475            "",
476            "missing diagnostics stay absent instead of being published as E0 W0"
477        );
478        assert!(
479            wire_rx.try_recv().is_err(),
480            "response published more than once"
481        );
482        publish.complete_unavailable();
483    }
484
485    #[test]
486    fn published_segment_bytes_are_self_labeled() {
487        let counts = StatusBarCounts {
488            errors: 2,
489            warnings: 5,
490            dead_code: 331,
491            unused_exports: 221,
492            duplicates: 1159,
493            todos: 8,
494            tier2_stale: false,
495        };
496        assert_eq!(
497            format!("[{}]", aft_status_segment(&counts)),
498            "[AFT E2 W5 | D331 U221 C1159 | T8]"
499        );
500    }
501
502    #[test]
503    fn shutdown_delivery_emits_terminal_before_removing_entry() {
504        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
505        let mut pending = PendingResponses::default();
506        pending.register(PendingResponse {
507            request_id: "inspect-shutdown".to_string(),
508            session_id: String::new(),
509            attach_command: String::new(),
510            poll: Box::new(|_| None),
511            cancellation: None,
512            on_shutdown: Some(Box::new(|_| {
513                Response::error("inspect-shutdown", "daemon_shutdown", "shutdown")
514            })),
515        });
516
517        let resolved = pending.drain_on_shutdown_with(&ctx);
518        assert_eq!(resolved.len(), 1);
519        assert_eq!(resolved[0].response.id, "inspect-shutdown");
520        assert!(pending.is_empty());
521    }
522
523    #[test]
524    fn published_segment_stale_marker_bytes_are_self_labeled() {
525        let counts = StatusBarCounts {
526            dead_code: 10,
527            tier2_stale: true,
528            ..StatusBarCounts::default()
529        };
530        assert_eq!(
531            format!("[{}]", aft_status_segment(&counts)),
532            "[AFT E0 W0 | ~D10 U0 C0 | T0]"
533        );
534    }
535}