Skip to main content

lattice_ai/mcp/
diff.rs

1//! IDE-protocol I4: the `openDiff` tool — open an interactive diff and BLOCK
2//! until the user Keeps or Rejects it.
3//!
4//! Unlike the I3 write tools (which send an `Effect` on the generic handler bus
5//! and return an optimistic ack), `openDiff` is *blocking* and opens a diff
6//! whose host-side machinery is irreducibly `&mut Editor` + lattice-diff types.
7//! So it rides a SECOND, host-drained bus
8//! ([`lattice_diff::ProgrammaticDiffBus`], built via `boot.inbound_raw` and
9//! registered as a service): this tool delegates to
10//! [`lattice_agent::review_diff`], which builds a
11//! [`lattice_diff::ProgrammaticDiffRequest`], `send`s it (which wakes the
12//! editor), and `await`s the request's completion oneshot — with **no
13//! timeout** (the user reviews at their own pace). The host opens a
14//! side-by-side diff, binds the oneshot to the session, and fires the
15//! [`DiffOutcome`](lattice_diff::subsystem::DiffOutcome) on
16//! `:diff-accept` / `:diff-reject` (or a close-tab cancel, which drops the
17//! sender → a graceful reject here).
18//!
19//! Reply shape (PROVISIONAL until validated against a live `claude` CLI): the
20//! MCP `CallToolResult` content carries a `FILE_SAVED` marker on Accept (the
21//! host already wrote the proposed content to `old_file_path` — the review IS
22//! the save) or `DIFF_REJECTED` on Reject. The agent branches on these markers.
23
24use std::path::PathBuf;
25
26use serde_json::{Value, json};
27
28use lattice_agent::{AgentError, DiffReviewRequest, review_diff};
29use lattice_diff::ProgrammaticDiffBus;
30use lattice_diff::subsystem::DiffOutcome;
31
32/// `openDiff`: open an interactive diff between `old_file_path`'s on-disk
33/// content and `new_file_contents`, blocking until the user resolves it.
34///
35/// Returns the full MCP `CallToolResult` envelope (NOT wrapped by the dispatch's
36/// `tool_text_result`, since the content markers are the contract). Every
37/// failure path is graceful — missing args, an absent bus, or a dropped
38/// receiver all return a result rather than hanging or panicking.
39pub async fn open_diff(
40    bus: Option<&ProgrammaticDiffBus>,
41    args: &Value,
42    // D-fix.6: the originating connection id — tags the diff so a later
43    // session-scoped close from THIS connection tears it down.
44    conn_id: u64,
45    // D-fix.6 follow-up: the shared pending-review tracker. A guard is held
46    // across the blocking `await` so the modeline shows a `◆ review` badge
47    // while the agent is blocked on the user, cleared on any outcome.
48    review: &crate::mcp::status::ReviewHandle,
49) -> Value {
50    let Some(bus) = bus else {
51        return error_result("openDiff unavailable: IDE server not fully initialized");
52    };
53    // `old_file_path` (baseline) + `new_file_contents` (proposed) are required;
54    // `new_file_path` defaults to `old_file_path` (an in-place edit).
55    let Some(old) = args.get("old_file_path").and_then(|v| v.as_str()) else {
56        return error_result("openDiff: missing old_file_path");
57    };
58    let Some(contents) = args.get("new_file_contents").and_then(|v| v.as_str()) else {
59        return error_result("openDiff: missing new_file_contents");
60    };
61    let new_path = args
62        .get("new_file_path")
63        .and_then(|v| v.as_str())
64        .unwrap_or(old);
65    let tab = args
66        .get("tab_name")
67        .and_then(|v| v.as_str())
68        .unwrap_or("openDiff");
69
70    let request = DiffReviewRequest {
71        old_file_path: PathBuf::from(old),
72        new_file_path: PathBuf::from(new_path),
73        new_contents: contents.to_string(),
74        tab_name: tab.to_string(),
75        origin_session: conn_id,
76    };
77
78    // D-fix.6 follow-up: a review is now pending for the modeline badge. The
79    // guard clears it on ANY exit below -- resolve, cancel, or the task being
80    // dropped -- so the count can never leak high. The badge is a modeline
81    // concern, so it stays adapter-side rather than moving into the port.
82    let _review = review.begin();
83
84    match review_diff(bus, request).await {
85        Ok(DiffOutcome::Accept) => saved_result(),
86        // Reject, a cancelled review (dropped sender), or any future
87        // `DiffOutcome` variant (the enum is `#[non_exhaustive]`) -> "not
88        // saved": a reject reply, so the agent never hangs.
89        Ok(_) | Err(AgentError::Cancelled(_)) => rejected_result(tab),
90        Err(_) => error_result("openDiff failed: editor not reachable"),
91    }
92}
93
94/// Accept reply: the host already persisted the proposed content.
95fn saved_result() -> Value {
96    text_result("FILE_SAVED", false)
97}
98
99/// Reject reply: the marker plus the tab name (so the agent can correlate).
100fn rejected_result(tab: &str) -> Value {
101    json!({
102        "content": [
103            { "type": "text", "text": "DIFF_REJECTED" },
104            { "type": "text", "text": tab },
105        ],
106        "isError": false,
107    })
108}
109
110/// A graceful failure envelope (`isError: true`) — never a hang or panic.
111fn error_result(message: &str) -> Value {
112    text_result(message, true)
113}
114
115/// One-text-block `CallToolResult` envelope.
116fn text_result(text: &str, is_error: bool) -> Value {
117    json!({
118        "content": [{ "type": "text", "text": text }],
119        "isError": is_error,
120    })
121}
122
123#[cfg(test)]
124mod tests {
125    #![allow(clippy::unwrap_used)]
126    use super::*;
127    use lattice_diff::ProgrammaticDiffRequest;
128    use lattice_mode::inbound::make_inbound_raw;
129    use std::sync::Arc;
130    use std::time::Duration;
131    use tokio::sync::Notify;
132
133    /// A throwaway pending-review tracker for the openDiff tests.
134    fn review() -> crate::mcp::status::ReviewHandle {
135        crate::mcp::status::ReviewState::new(Arc::new(Notify::new()))
136    }
137
138    #[tokio::test]
139    async fn no_bus_is_graceful_error() {
140        let v = open_diff(
141            None,
142            &json!({ "old_file_path": "/a.rs", "new_file_contents": "x" }),
143            0,
144            &review(),
145        )
146        .await;
147        assert_eq!(v["isError"], true);
148        assert!(
149            v["content"][0]["text"]
150                .as_str()
151                .unwrap()
152                .contains("not fully initialized")
153        );
154    }
155
156    #[tokio::test]
157    async fn missing_required_args_are_graceful_errors() {
158        let (bus, _rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
159        // Missing new_file_contents.
160        let v = open_diff(
161            Some(&bus),
162            &json!({ "old_file_path": "/a.rs" }),
163            0,
164            &review(),
165        )
166        .await;
167        assert_eq!(v["isError"], true);
168        assert!(
169            v["content"][0]["text"]
170                .as_str()
171                .unwrap()
172                .contains("missing new_file_contents")
173        );
174        // Missing old_file_path.
175        let v = open_diff(
176            Some(&bus),
177            &json!({ "new_file_contents": "x" }),
178            0,
179            &review(),
180        )
181        .await;
182        assert!(
183            v["content"][0]["text"]
184                .as_str()
185                .unwrap()
186                .contains("missing old_file_path")
187        );
188    }
189
190    #[tokio::test]
191    async fn accept_yields_file_saved() {
192        let (bus, mut rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
193        let call = tokio::spawn({
194            let bus = bus.clone();
195            async move {
196                open_diff(
197                    Some(&bus),
198                    &json!({
199                        "old_file_path": "/a.rs",
200                        "new_file_contents": "new",
201                        "tab_name": "demo",
202                    }),
203                    0,
204                    &review(),
205                )
206                .await
207            }
208        });
209        // Play the host: drain the request, resolve its oneshot with Accept.
210        let req = recv_soon(&mut rx).await;
211        assert_eq!(req.old_file_path, PathBuf::from("/a.rs"));
212        assert_eq!(req.new_file_path, PathBuf::from("/a.rs"), "defaults to old");
213        assert_eq!(req.new_contents, "new");
214        req.response.send(DiffOutcome::Accept).unwrap();
215
216        let v = call.await.unwrap();
217        assert_eq!(v["isError"], false);
218        assert_eq!(v["content"][0]["text"], "FILE_SAVED");
219    }
220
221    #[tokio::test]
222    async fn reject_yields_diff_rejected_with_tab() {
223        let (bus, mut rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
224        let call = tokio::spawn({
225            let bus = bus.clone();
226            async move {
227                open_diff(
228                    Some(&bus),
229                    &json!({
230                        "old_file_path": "/a.rs",
231                        "new_file_path": "/b.rs",
232                        "new_file_contents": "new",
233                        "tab_name": "the-tab",
234                    }),
235                    0,
236                    &review(),
237                )
238                .await
239            }
240        });
241        let req = recv_soon(&mut rx).await;
242        assert_eq!(
243            req.new_file_path,
244            PathBuf::from("/b.rs"),
245            "explicit new path"
246        );
247        req.response.send(DiffOutcome::Reject).unwrap();
248
249        let v = call.await.unwrap();
250        assert_eq!(v["content"][0]["text"], "DIFF_REJECTED");
251        assert_eq!(v["content"][1]["text"], "the-tab");
252    }
253
254    #[tokio::test]
255    async fn dropped_sender_is_treated_as_reject() {
256        // A close-tab cancel drops the bound sender; the awaiting agent must get
257        // a graceful reject, never a hang.
258        let (bus, mut rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
259        let call = tokio::spawn({
260            let bus = bus.clone();
261            async move {
262                open_diff(
263                    Some(&bus),
264                    &json!({
265                        "old_file_path": "/a.rs",
266                        "new_file_contents": "new",
267                        "tab_name": "cancelled",
268                    }),
269                    0,
270                    &review(),
271                )
272                .await
273            }
274        });
275        let req = recv_soon(&mut rx).await;
276        drop(req.response); // session cancelled without an explicit outcome
277        let v = call.await.unwrap();
278        assert_eq!(v["content"][0]["text"], "DIFF_REJECTED");
279        assert_eq!(v["content"][1]["text"], "cancelled");
280    }
281
282    /// Poll the receiver briefly until the spawned `open_diff` has sent its
283    /// request (the `send` happens after the task is scheduled).
284    async fn recv_soon(
285        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ProgrammaticDiffRequest>,
286    ) -> ProgrammaticDiffRequest {
287        for _ in 0..100 {
288            if let Ok(req) = rx.try_recv() {
289                return req;
290            }
291            tokio::time::sleep(Duration::from_millis(5)).await;
292        }
293        panic!("openDiff request never arrived on the bus");
294    }
295}