1 //! End-to-end check that a multi-root project reaches the model.
2 //!
3 //! Zed (and any other ACP client that can open several directories at once)
4 //! sends the extra ones as `additionalDirectories` on `session/new`. This
5 //! spawns the real binary in ACP mode against a scripted OpenAI-compatible
6 //! endpoint, opens a session with two roots, and inspects what the endpoint was
7 //! actually sent: the tool list must include a skill that only exists in the
8 //! *second* root, which is only true if that root was recorded and
9 //! project-local discovery scanned it.
10 //!
11 //! The system context that names the roots is not asserted here. This test
12 //! drives the agent through the `OPENAI_BASE_URL` override, and that backend is
13 //! built once at startup — the per-session context message goes into the local
14 //! engine's history, which the override never reads. `session_context_message`
15 //! and the instruction loader are covered by unit tests instead.
16
17 use std::collections::VecDeque;
18 use std::io::{BufRead, BufReader, Read, Write};
19 use std::net::TcpListener;
20 use std::process::{Child, ChildStdin, Command, Stdio};
21 use std::sync::mpsc::{Receiver, channel};
22 use std::sync::{Arc, Mutex};
23 use std::time::{Duration, Instant};
24
25 use serde_json::{Value, json};
26
27 const TIMEOUT: Duration = Duration::from_secs(60);
28
29 /// Serves one scripted SSE response per request and records each request body.
30 struct FakeEndpoint {
31 port: u16,
32 requests: Arc<Mutex<Vec<Value>>>,
33 }
34
35 fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
36 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
37 let port = listener.local_addr().unwrap().port();
38 let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
39 let recorded = Arc::clone(&requests);
40 let queue = Mutex::new(VecDeque::from(responses));
41
42 std::thread::spawn(move || {
43 for stream in listener.incoming() {
44 let Ok(mut stream) = stream else { continue };
45 let mut reader = BufReader::new(match stream.try_clone() {
46 Ok(clone) => clone,
47 Err(_) => continue,
48 });
49 let mut content_length = 0usize;
50 loop {
51 let mut line = String::new();
52 if reader.read_line(&mut line).unwrap_or(0) == 0 {
53 break;
54 }
55 let line = line.trim();
56 if line.is_empty() {
57 break;
58 }
59 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
60 content_length = length.trim().parse().unwrap_or(0);
61 }
62 }
63 let mut body = vec![0u8; content_length];
64 if reader.read_exact(&mut body).is_err() {
65 continue;
66 }
67 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
68 recorded.lock().unwrap().push(request);
69 }
70 let payload = queue
71 .lock()
72 .unwrap()
73 .pop_front()
74 .unwrap_or_else(|| "data: [DONE]\n\n".to_string());
75 let response = format!(
76 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
77 content-length: {}\r\nconnection: close\r\n\r\n{}",
78 payload.len(),
79 payload
80 );
81 let _ = stream.write_all(response.as_bytes());
82 }
83 });
84
85 FakeEndpoint { port, requests }
86 }
87
88 fn sse_text(text: &str) -> String {
89 let event = json!({"choices": [{"delta": {"content": text}}]});
90 format!("data: {event}\n\ndata: [DONE]\n\n")
91 }
92
93 struct AgentUnderTest {
94 child: Child,
95 stdin: ChildStdin,
96 incoming: Receiver<Value>,
97 next_id: u64,
98 }
99
100 fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
101 let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
102 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
103 .env("OPENAI_API_KEY", "test-key")
104 .env("SIGIT_MODEL", "scripted-model")
105 .env("SIGIT_CONFIG_DIR", config_dir)
106 .env("SIGIT_MCP", "off")
107 .env("SIGIT_PERMISSIONS", "allow")
108 .env_remove("SIGIT_LOCAL_INFERENCE")
109 .stdin(Stdio::piped())
110 .stdout(Stdio::piped())
111 .stderr(Stdio::null())
112 .spawn()
113 .expect("spawn sigit in ACP mode");
114
115 let stdout = child.stdout.take().unwrap();
116 let (message_tx, incoming) = channel();
117 std::thread::spawn(move || {
118 for line in BufReader::new(stdout).lines() {
119 let Ok(line) = line else { break };
120 if let Ok(message) = serde_json::from_str::<Value>(&line)
121 && message_tx.send(message).is_err()
122 {
123 break;
124 }
125 }
126 });
127
128 let stdin = child.stdin.take().unwrap();
129 AgentUnderTest {
130 child,
131 stdin,
132 incoming,
133 next_id: 0,
134 }
135 }
136
137 impl AgentUnderTest {
138 fn request(&mut self, method: &str, params: Value) -> u64 {
139 self.next_id += 1;
140 let id = self.next_id;
141 let mut line =
142 json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
143 line.push('\n');
144 self.stdin
145 .write_all(line.as_bytes())
146 .expect("write to agent stdin");
147 self.stdin.flush().expect("flush agent stdin");
148 id
149 }
150
151 fn wait_for_response(&mut self, id: u64) -> Value {
152 let deadline = Instant::now() + TIMEOUT;
153 loop {
154 let remaining = deadline.saturating_duration_since(Instant::now());
155 let Ok(message) = self.incoming.recv_timeout(remaining) else {
156 panic!("timed out waiting for the response to request {id}");
157 };
158 if message["id"] == id && message.get("method").is_none() {
159 assert!(
160 message.get("error").is_none(),
161 "request {id} failed: {message}"
162 );
163 return message;
164 }
165 }
166 }
167 }
168
169 impl Drop for AgentUnderTest {
170 fn drop(&mut self) {
171 let _ = self.child.kill();
172 let _ = self.child.wait();
173 }
174 }
175
176 #[test]
177 fn a_skill_from_a_second_root_is_offered_to_the_model() {
178 let endpoint = start_fake_endpoint(vec![sse_text("looked at both roots")]);
179
180 let scratch = std::env::temp_dir().join(format!("sigit_acp_roots_{}", std::process::id()));
181 let config_dir = scratch.join("config");
182 let primary = scratch.join("primary");
183 let secondary = scratch.join("secondary");
184 std::fs::create_dir_all(&config_dir).unwrap();
185 std::fs::create_dir_all(&primary).unwrap();
186 std::fs::create_dir_all(&secondary).unwrap();
187 // A skill that exists only in the second root.
188 let skill_dir = secondary.join(".sigit").join("skills").join("second-root");
189 std::fs::create_dir_all(&skill_dir).unwrap();
190 std::fs::write(
191 skill_dir.join("SKILL.md"),
192 "---\nname: second-root\ndescription: Only reachable through the second root\n---\n\nDo the thing.\n",
193 )
194 .unwrap();
195
196 let mut agent = spawn_agent(endpoint.port, &config_dir);
197
198 let id = agent.request(
199 "initialize",
200 json!({"protocolVersion": 1, "clientCapabilities": {}}),
201 );
202 let initialize = agent.wait_for_response(id);
203
204 // Zed only sends `additionalDirectories` to an agent that advertises this;
205 // without it the client keeps the first root, drops the rest, and tells the
206 // user the agent has no multi-root support.
207 assert!(
208 initialize["result"]["agentCapabilities"]["sessionCapabilities"]["additionalDirectories"]
209 .is_object(),
210 "additionalDirectories capability missing from {initialize}"
211 );
212
213 let id = agent.request(
214 "session/new",
215 json!({
216 "cwd": primary,
217 "mcpServers": [],
218 "additionalDirectories": [secondary],
219 }),
220 );
221 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
222 .as_str()
223 .expect("session id")
224 .to_string();
225
226 let id = agent.request(
227 "session/prompt",
228 json!({
229 "sessionId": session_id,
230 "prompt": [{"type": "text", "text": "where are we?"}],
231 }),
232 );
233 agent.wait_for_response(id);
234
235 let requests = endpoint.requests.lock().unwrap();
236 let request = requests.first().expect("one completion request");
237
238 let skill_tool = request["tools"]
239 .as_array()
240 .expect("tools")
241 .iter()
242 .find(|tool| tool["function"]["name"] == "skill")
243 .expect("the skill tool is offered when a root has skills");
244 assert!(
245 skill_tool["function"]["description"]
246 .as_str()
247 .unwrap_or_default()
248 .contains("second-root"),
249 "a skill from the second root must be discoverable, got: {skill_tool}"
250 );
251
252 drop(requests);
253 let _ = std::fs::remove_dir_all(&scratch);
254 }