Skip to main content

aft/
response_finalize.rs

1use crate::context::AppContext;
2use crate::protocol::Response;
3
4/// Apply finalizers in the established response order: background completions first, then status bar counts.
5pub fn finalize_response(
6    response: &mut Response,
7    ctx: &AppContext,
8    session_id: &str,
9    attach_command: &str,
10) {
11    finalize_response_with_bg_completions(response, ctx, session_id, attach_command, true);
12}
13
14pub fn finalize_response_with_bg_completions(
15    response: &mut Response,
16    ctx: &AppContext,
17    session_id: &str,
18    attach_command: &str,
19    allow_bg_completions: bool,
20) {
21    if allow_bg_completions {
22        attach_bg_completions(response, ctx, session_id, attach_command);
23    }
24    attach_status_bar(response, ctx, session_id, attach_command);
25}
26
27pub enum DispatchOutcome {
28    Immediate(Response),
29    Deferred(PendingResponse),
30}
31
32pub type PendingResponsePoll = Box<dyn FnMut(&AppContext) -> Option<Response>>;
33
34pub struct PendingResponse {
35    pub request_id: String,
36    pub session_id: String,
37    pub attach_command: String,
38    pub poll: PendingResponsePoll,
39}
40
41pub struct ResolvedPending {
42    pub response: Response,
43    pub session_id: String,
44    pub attach_command: String,
45}
46
47#[derive(Default)]
48pub struct PendingResponses {
49    entries: Vec<PendingResponse>,
50}
51
52impl PendingResponses {
53    pub fn register(&mut self, pending: PendingResponse) {
54        self.entries
55            .retain(|entry| entry.request_id != pending.request_id);
56        self.entries.push(pending);
57    }
58
59    pub fn poll_ready(&mut self, ctx: &AppContext) -> Vec<ResolvedPending> {
60        let mut ready = Vec::new();
61        let mut waiting = Vec::with_capacity(self.entries.len());
62
63        for mut pending in self.entries.drain(..) {
64            if let Some(response) = (pending.poll)(ctx) {
65                ready.push(ResolvedPending {
66                    response,
67                    session_id: pending.session_id,
68                    attach_command: pending.attach_command,
69                });
70            } else {
71                waiting.push(pending);
72            }
73        }
74
75        self.entries = waiting;
76        ready
77    }
78
79    pub fn is_empty(&self) -> bool {
80        self.entries.is_empty()
81    }
82
83    pub fn drain_on_shutdown(&mut self) {
84        self.entries.clear();
85    }
86}
87
88pub fn attach_bg_completions(
89    response: &mut Response,
90    ctx: &AppContext,
91    session_id: &str,
92    command: &str,
93) {
94    if matches!(
95        command,
96        "configure"
97            | "bash_abort_inflight"
98            | "bash_status"
99            | "bash_write"
100            | "bash_promote"
101            | "bash_wait_detach"
102            | "bash_regex_match"
103            | "bash_drain_completions"
104            | "bash_notify"
105            | "bash_unnotify"
106            | "bash_ack_completions"
107    ) {
108        return;
109    }
110    if !ctx
111        .bash_background()
112        .has_completions_for_session(Some(session_id))
113    {
114        return;
115    }
116    let completions = ctx
117        .bash_background()
118        .drain_completions_for_session(Some(session_id));
119    if completions.is_empty() {
120        return;
121    }
122    let value = serde_json::json!(completions);
123    match response.data.as_object_mut() {
124        Some(data) => {
125            data.insert("bg_completions".to_string(), value);
126        }
127        None => {
128            response.data = serde_json::json!({ "bg_completions": value });
129        }
130    }
131}
132
133fn aft_status_segment(counts: &crate::context::StatusBarCounts) -> String {
134    let stale_mark = if counts.tier2_stale { "~" } else { "" };
135    format!(
136        "E{} W{} | {}D{} U{} C{} | T{}",
137        counts.errors,
138        counts.warnings,
139        stale_mark,
140        counts.dead_code,
141        counts.unused_exports,
142        counts.duplicates,
143        counts.todos
144    )
145}
146
147fn holder_owns_status_bar(plane_live: bool, harness: Option<&crate::harness::Harness>) -> bool {
148    plane_live && matches!(harness, Some(crate::harness::Harness::Opencode))
149}
150
151/// Attach the agent status-bar counts to the response envelope so the plugin
152/// after-hook can surface the IDE-style status bar (emit-on-change). Skips
153/// internal/transport commands that don't represent agent tool calls (their
154/// responses never reach the agent, and bash-lifecycle commands fire rapidly).
155/// `errors`/`warnings` are read live from the LSP store. Tier-2 and todo counts
156/// come from a cached snapshot, so the payload stays omitted until that snapshot
157/// has been populated.
158pub fn attach_status_bar(
159    response: &mut Response,
160    ctx: &AppContext,
161    session_id: &str,
162    command: &str,
163) {
164    // Cross-root indexed searches report on a borrowed project, so attaching the
165    // session project's diagnostics footer would falsely attribute unrelated
166    // counts to the external results. The command sets this private marker and
167    // the finalizer removes it before the response reaches the caller.
168    if response
169        .data
170        .as_object_mut()
171        .and_then(|data| data.remove("_aft_suppress_status_bar"))
172        .is_some()
173    {
174        return;
175    }
176    if matches!(
177        command,
178        "configure"
179            | "ping"
180            | "version"
181            | "status"
182            | "bash_abort_inflight"
183            | "bash_status"
184            | "bash_write"
185            | "bash_promote"
186            | "bash_wait_detach"
187            | "bash_regex_match"
188            | "bash_drain_completions"
189            | "bash_notify"
190            | "bash_unnotify"
191            | "bash_ack_completions"
192    ) {
193        return;
194    }
195    let local_counts = ctx.status_bar_counts();
196    let harness = ctx.harness_opt();
197    let plane_live = ctx.fleet_status_client().is_some_and(|client| {
198        let config = ctx.config();
199        let Some(project_root) = config.project_root.as_deref() else {
200            return false;
201        };
202        let harness_label = harness
203            .as_ref()
204            .map(crate::harness::Harness::wire_label)
205            .unwrap_or_else(|| "unknown".to_string());
206        let aft_text = local_counts
207            .as_ref()
208            .map(aft_status_segment)
209            .unwrap_or_default();
210        client.publish(project_root, &harness_label, session_id, &aft_text)
211    });
212    if holder_owns_status_bar(plane_live, harness.as_ref()) {
213        return;
214    }
215    let Some(counts) = local_counts else {
216        return;
217    };
218    if !ctx.should_emit_status_bar(&counts) {
219        return;
220    }
221    let value = serde_json::json!({
222        "errors": counts.errors,
223        "warnings": counts.warnings,
224        "dead_code": counts.dead_code,
225        "unused_exports": counts.unused_exports,
226        "duplicates": counts.duplicates,
227        "todos": counts.todos,
228        "tier2_stale": counts.tier2_stale,
229    });
230    match response.data.as_object_mut() {
231        Some(data) => {
232            data.insert("status_bar".to_string(), value);
233        }
234        None => {
235            response.data = serde_json::json!({ "status_bar": value });
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::{aft_status_segment, holder_owns_status_bar};
243    use crate::context::StatusBarCounts;
244    use crate::harness::Harness;
245
246    #[test]
247    fn live_holder_retires_only_opencode_response_bars() {
248        assert_eq!(
249            (
250                holder_owns_status_bar(true, Some(&Harness::Opencode)),
251                holder_owns_status_bar(true, Some(&Harness::Runner)),
252            ),
253            (true, false)
254        );
255        assert!(!holder_owns_status_bar(true, Some(&Harness::Pi)));
256        assert!(!holder_owns_status_bar(false, Some(&Harness::Opencode)));
257    }
258
259    #[test]
260    fn solo_bar_bytes_remain_the_existing_golden() {
261        let counts = StatusBarCounts {
262            errors: 2,
263            warnings: 5,
264            dead_code: 331,
265            unused_exports: 221,
266            duplicates: 1159,
267            todos: 8,
268            tier2_stale: false,
269        };
270        assert_eq!(
271            format!("[AFT {}]", aft_status_segment(&counts)),
272            "[AFT E2 W5 | D331 U221 C1159 | T8]"
273        );
274    }
275
276    #[test]
277    fn solo_bar_stale_marker_bytes_remain_the_existing_golden() {
278        let counts = StatusBarCounts {
279            dead_code: 10,
280            tier2_stale: true,
281            ..StatusBarCounts::default()
282        };
283        assert_eq!(
284            format!("[AFT {}]", aft_status_segment(&counts)),
285            "[AFT E0 W0 | ~D10 U0 C0 | T0]"
286        );
287    }
288}