Skip to main content

lattice_agent/
diff_review.rs

1//! The diff-review seam: propose an edit, block until the user rules on it.
2//!
3//! This is the one operation both agent protocols share verbatim. MCP's
4//! `openDiff` and ACP's `session/request_permission` are two encodings of it.
5//! The producer/awaiter lives here; the host owns the matching receiver and
6//! resolves the reply when the user runs `:diff-accept` / `:diff-reject`.
7
8use std::path::PathBuf;
9
10use lattice_diff::subsystem::DiffOutcome;
11use lattice_diff::{ProgrammaticDiffBus, ProgrammaticDiffRequest};
12use tokio::sync::oneshot;
13
14use crate::error::{AgentError, Result};
15
16/// A proposed edit awaiting the user's verdict.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct DiffReviewRequest {
19    /// Baseline path; its on-disk content is the left (read-only) side.
20    pub old_file_path: PathBuf,
21    /// Path the proposed content carries. An Accept writes the right side here.
22    /// Usually equals `old_file_path` (an in-place edit).
23    pub new_file_path: PathBuf,
24    /// The proposed text — the editable right side.
25    pub new_contents: String,
26    /// Display label for the diff. Presentation only; teardown keys on
27    /// `origin_session`.
28    pub tab_name: String,
29    /// The originating agent session. Tags the diff so a later session-scoped
30    /// close from *this* session tears it down. `0` means "no origin".
31    pub origin_session: u64,
32}
33
34/// Send `req` to the editor and block until the user resolves it.
35///
36/// No timeout: the user reviews at their own pace. A dropped reply channel
37/// means the session was cancelled (the diff was closed, `:diffoff`, the editor
38/// went away) and surfaces as [`AgentError::Cancelled`] — never a hang.
39pub async fn review_diff(bus: &ProgrammaticDiffBus, req: DiffReviewRequest) -> Result<DiffOutcome> {
40    let (tx, rx) = oneshot::channel::<DiffOutcome>();
41    let request = ProgrammaticDiffRequest {
42        old_file_path: req.old_file_path,
43        new_file_path: req.new_file_path,
44        new_contents: req.new_contents,
45        tab_name: req.tab_name,
46        origin_session: req.origin_session,
47        response: tx,
48    };
49    if bus.send(request).is_err() {
50        return Err(AgentError::Bus("programmatic diff receiver is gone".into()));
51    }
52    rx.await
53        .map_err(|_| AgentError::Cancelled("diff review was dismissed".into()))
54}
55
56#[cfg(test)]
57mod tests {
58    use lattice_mode::inbound::make_inbound_raw;
59    use std::sync::Arc;
60    use tokio::sync::Notify;
61
62    use super::*;
63
64    fn req(session: u64) -> DiffReviewRequest {
65        DiffReviewRequest {
66            old_file_path: PathBuf::from("/tmp/a.rs"),
67            new_file_path: PathBuf::from("/tmp/a.rs"),
68            new_contents: "fn main() {}\n".to_string(),
69            tab_name: "openDiff".to_string(),
70            origin_session: session,
71        }
72    }
73
74    /// The request reaches the host with every field intact, and the host's
75    /// verdict comes back to the caller.
76    #[tokio::test]
77    async fn accept_round_trips_through_the_bus() {
78        let (bus, mut rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
79
80        let host = tokio::spawn(async move {
81            let request = rx.recv().await.expect("a request should arrive");
82            assert_eq!(request.old_file_path, PathBuf::from("/tmp/a.rs"));
83            assert_eq!(request.new_contents, "fn main() {}\n");
84            assert_eq!(request.origin_session, 7);
85            request
86                .response
87                .send(DiffOutcome::Accept)
88                .expect("caller is still awaiting");
89        });
90
91        let outcome = review_diff(&bus, req(7)).await.expect("accept");
92        assert_eq!(outcome, DiffOutcome::Accept);
93        host.await.expect("host task");
94    }
95
96    #[tokio::test]
97    async fn reject_round_trips_through_the_bus() {
98        let (bus, mut rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
99        tokio::spawn(async move {
100            let request = rx.recv().await.expect("a request should arrive");
101            let _ = request.response.send(DiffOutcome::Reject);
102        });
103        assert_eq!(
104            review_diff(&bus, req(1)).await.expect("reject"),
105            DiffOutcome::Reject
106        );
107    }
108
109    /// A dropped reply channel is a cancelled review, not a hang. This is the
110    /// case that fires when the user closes the diff or the session dies.
111    #[tokio::test]
112    async fn dropped_reply_channel_is_cancelled_not_a_hang() {
113        let (bus, mut rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
114        tokio::spawn(async move {
115            let request = rx.recv().await.expect("a request should arrive");
116            drop(request); // the host gave up without answering
117        });
118        assert!(matches!(
119            review_diff(&bus, req(1)).await,
120            Err(AgentError::Cancelled(_))
121        ));
122    }
123
124    /// No host draining the bus at all — a boot misconfiguration.
125    #[tokio::test]
126    async fn dropped_receiver_is_a_bus_error() {
127        let (bus, rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
128        drop(rx);
129        assert!(matches!(
130            review_diff(&bus, req(1)).await,
131            Err(AgentError::Bus(_))
132        ));
133    }
134}