Skip to main content

aft/
response_finalize.rs

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