Skip to main content

lattice_ai/mcp/
writes.rs

1//! IDE-protocol I3: the three write tools.
2//!
3//! Thin MCP envelope builders over [`lattice_agent::EditorAccess`]'s write
4//! half (`open_file` / `save_document` / `close_tab` / `close_session_diffs`):
5//! marshal arguments, call the port, and shape the result. All the
6//! validation + Effect mapping + the bus send/await/timeout lives in the
7//! port (`lattice_agent::write_bus` + `EditorAccess::run_write`); these
8//! functions only parse arguments and translate the port's [`AgentError`]
9//! back into the MCP reply strings the agent has always seen.
10//!
11//! Every failure path is graceful: missing/absent bus, a dropped receiver
12//! (server stopped), or a timeout all return `success: false` with a
13//! message — never a hang, never a panic.
14
15use std::path::PathBuf;
16
17use serde_json::{Value, json};
18
19use lattice_agent::{AgentError, EditorAccess};
20use lattice_grammar::Utf16Pos;
21
22/// `openFile`: open `filePath`, optionally at a `selection` start position.
23pub async fn open_file(editor: &EditorAccess, args: &Value) -> Value {
24    let Some(path) = args.get("filePath").and_then(|v| v.as_str()) else {
25        return result(false, "openFile: missing filePath");
26    };
27    let column = parse_position(args);
28    match editor.open_file(PathBuf::from(path), column).await {
29        Ok(()) => result(true, "ok"),
30        Err(e) => result(false, &write_error_message(e)),
31    }
32}
33
34/// `saveDocument`: save the document for `filePath`.
35pub async fn save_document(editor: &EditorAccess, args: &Value) -> Value {
36    let Some(path) = args.get("filePath").and_then(|v| v.as_str()) else {
37        return result(false, "saveDocument: missing filePath");
38    };
39    match editor.save_document(PathBuf::from(path)).await {
40        Ok(()) => result(true, "ok"),
41        Err(e) => result(false, &write_error_message(e)),
42    }
43}
44
45/// `close_tab`: close the tab named `tab_name`, scoped to connection
46/// `conn_id` (D-fix.6) — the host rejects THAT connection's diff session(s).
47pub async fn close_tab(editor: &EditorAccess, args: &Value, conn_id: u64) -> Value {
48    let Some(tab) = args.get("tab_name").and_then(|v| v.as_str()) else {
49        return result(false, "close_tab: missing tab_name");
50    };
51    match editor.close_tab(conn_id, tab.to_string()).await {
52        Ok(()) => result(true, "ok"),
53        Err(e) => result(false, &write_error_message(e)),
54    }
55}
56
57/// D-fix.6 `closeAllDiffTabs`: reject every programmatic diff connection
58/// `conn_id` opened. Takes no args (the connection id IS the scope).
59pub async fn close_all_diff_tabs(editor: &EditorAccess, conn_id: u64) -> Value {
60    match editor.close_session_diffs(conn_id).await {
61        Ok(()) => result(true, "ok"),
62        Err(e) => result(false, &write_error_message(e)),
63    }
64}
65
66/// Unwrap an [`AgentError`] back to the ORIGINAL message text, discarding
67/// `Display`'s variant prefix (`"editor not reachable: "` / `"editor io
68/// error: "`). Using `e.to_string()` here would double-prefix the reply the
69/// agent sees (e.g. `"editor not reachable: write failed: editor not
70/// reachable"`) — a silent wire-format regression. This is the one place
71/// that translation happens, so every write tool stays byte-identical to
72/// the pre-port replies.
73fn write_error_message(e: AgentError) -> String {
74    match e {
75        AgentError::Bus(m) | AgentError::Cancelled(m) | AgentError::Io(m) => m,
76    }
77}
78
79/// Parse an optional `selection.start` (`{line, character}`) into a UTF-16
80/// column for [`Effect::OpenBufferAtColumn`](lattice_grammar::Effect::OpenBufferAtColumn)
81/// — the host converts it to a byte offset against the *opened* line. The
82/// agent's `character` is VS Code-style UTF-16, passed through verbatim (BC.8c
83/// retired the earlier provisional byte interpretation). Returns `None` when the
84/// agent sent no selection: the file opens without forcing the cursor (so
85/// re-opening an already-open file keeps its position).
86fn parse_position(args: &Value) -> Option<Utf16Pos> {
87    let start = args.get("selection").and_then(|s| s.get("start"))?;
88    let line = start.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
89    let col = start.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
90    Some(Utf16Pos { line, col })
91}
92
93/// The write-tool result body. `success` mirrors the drain's optimistic-ack.
94fn result(success: bool, message: &str) -> Value {
95    json!({ "success": success, "message": message })
96}
97
98#[cfg(test)]
99mod tests {
100    #![allow(clippy::unwrap_used)]
101    use super::*;
102    use std::sync::{Arc, Mutex};
103
104    fn editor_without_bus() -> EditorAccess {
105        EditorAccess::new(
106            Arc::new(Mutex::new(lattice_agent::EditorStateCache::default())),
107            None,
108            vec![],
109            None,
110        )
111    }
112
113    #[tokio::test]
114    async fn write_with_no_bus_is_graceful_failure() {
115        let v = open_file(&editor_without_bus(), &json!({ "filePath": "/a.rs" })).await;
116        assert_eq!(v["success"], false);
117        assert!(
118            v["message"]
119                .as_str()
120                .unwrap()
121                .contains("not fully initialized")
122        );
123    }
124
125    #[tokio::test]
126    async fn missing_argument_is_graceful_failure() {
127        let v = open_file(&editor_without_bus(), &json!({})).await;
128        assert_eq!(v["success"], false);
129        assert!(v["message"].as_str().unwrap().contains("missing filePath"));
130        let v = close_tab(&editor_without_bus(), &json!({}), 0).await;
131        assert!(v["message"].as_str().unwrap().contains("missing tab_name"));
132    }
133
134    #[test]
135    fn parse_position_reads_selection_start_else_none() {
136        // No selection → None (open only, don't force the cursor).
137        assert_eq!(parse_position(&json!({})), None);
138        // A selection's `character` is a UTF-16 column, passed through verbatim.
139        let p = parse_position(&json!({ "selection": { "start": { "line": 4, "character": 2 } } }));
140        assert_eq!(p, Some(Utf16Pos { line: 4, col: 2 }));
141    }
142
143    #[tokio::test]
144    async fn open_file_round_trips_through_a_live_bus() {
145        use lattice_agent::EditorStateCache;
146        use lattice_mode::inbound::make_inbound;
147        use std::time::Duration;
148        use tokio::sync::Notify;
149
150        let cache = Arc::new(Mutex::new(EditorStateCache::default()));
151        let (bus, mut drain) = make_inbound(
152            Arc::new(Notify::new()),
153            lattice_agent::make_handler(cache.clone()),
154        );
155        let editor = EditorAccess::new(cache, None, vec![], Some(bus));
156
157        // The write awaits the reply; here we drive the drain (the "tick") that
158        // resolves it. Poll the drain until the queued request produces its
159        // Effect, then collect the tool's result.
160        let call =
161            tokio::spawn(async move { open_file(&editor, &json!({ "filePath": "/a.rs" })).await });
162        let mut effects = Vec::new();
163        for _ in 0..50 {
164            effects = drain();
165            if !effects.is_empty() {
166                break;
167            }
168            tokio::time::sleep(Duration::from_millis(5)).await;
169        }
170        assert_eq!(effects.len(), 1, "openFile maps to one Effect");
171        let v = call.await.unwrap();
172        assert_eq!(v["success"], true);
173    }
174}