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, 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_status"
98            | "bash_write"
99            | "bash_promote"
100            | "bash_wait_detach"
101            | "bash_regex_match"
102            | "bash_drain_completions"
103            | "bash_notify"
104            | "bash_unnotify"
105            | "bash_ack_completions"
106    ) {
107        return;
108    }
109    if !ctx
110        .bash_background()
111        .has_completions_for_session(Some(session_id))
112    {
113        return;
114    }
115    let completions = ctx
116        .bash_background()
117        .drain_completions_for_session(Some(session_id));
118    if completions.is_empty() {
119        return;
120    }
121    let value = serde_json::json!(completions);
122    match response.data.as_object_mut() {
123        Some(data) => {
124            data.insert("bg_completions".to_string(), value);
125        }
126        None => {
127            response.data = serde_json::json!({ "bg_completions": value });
128        }
129    }
130}
131
132/// Attach the agent status-bar counts to the response envelope so the plugin
133/// after-hook can surface the IDE-style status bar (emit-on-change). Skips
134/// internal/transport commands that don't represent agent tool calls (their
135/// responses never reach the agent, and bash-lifecycle commands fire rapidly).
136/// `errors`/`warnings` are read live from the LSP store here; Tier-2/todos are
137/// last-known. Omitted entirely until the Tier-2 cache is populated once.
138pub fn attach_status_bar(response: &mut Response, ctx: &AppContext, command: &str) {
139    // Cross-root indexed searches report on a borrowed project, so attaching the
140    // session project's diagnostics footer would falsely attribute unrelated
141    // counts to the external results. The command sets this private marker and
142    // the finalizer removes it before the response reaches the caller.
143    if response
144        .data
145        .as_object_mut()
146        .and_then(|data| data.remove("_aft_suppress_status_bar"))
147        .is_some()
148    {
149        return;
150    }
151    if matches!(
152        command,
153        "configure"
154            | "ping"
155            | "version"
156            | "status"
157            | "bash_status"
158            | "bash_write"
159            | "bash_promote"
160            | "bash_wait_detach"
161            | "bash_regex_match"
162            | "bash_drain_completions"
163            | "bash_notify"
164            | "bash_unnotify"
165            | "bash_ack_completions"
166    ) {
167        return;
168    }
169    let Some(counts) = ctx.status_bar_counts() else {
170        return;
171    };
172    if !ctx.should_emit_status_bar(&counts) {
173        return;
174    }
175    let value = serde_json::json!({
176        "errors": counts.errors,
177        "warnings": counts.warnings,
178        "dead_code": counts.dead_code,
179        "unused_exports": counts.unused_exports,
180        "duplicates": counts.duplicates,
181        "todos": counts.todos,
182        "tier2_stale": counts.tier2_stale,
183    });
184    match response.data.as_object_mut() {
185        Some(data) => {
186            data.insert("status_bar".to_string(), value);
187        }
188        None => {
189            response.data = serde_json::json!({ "status_bar": value });
190        }
191    }
192}