Tell the client we accept the extra roots

Zed gates additionalDirectories on the agent advertising sessionCapabilities.additionalDirectories at initialize. We never did, so the field never arrived, the previous commit's plumbing sat unreachable, and the editor showed a banner saying multi-root workspaces were unsupported and it would work on the first directory only. The capability is now in the initialize reply, and the e2e test asserts it, since everything else in that test passes just as well when the client is the one sending the roots. Also two things the review raised. The dedupe key for a root fell back to the untouched path when canonicalize failed, which lets "." and ".." segments or a relative spelling read as a new root; it now falls back to an absolute, lexically normalized path. instructions.rs was doing its own copy of that fallback and now shares workspace::canonical_key.

paydii committed Sep 5, 2026 at 00:37 UTC 252e354d1e1d48afd957f5cb204fdebef0d00d92
7 files changed +98 -18
.agents/AGENTS.md
+5 -1
@@ -226,7 +226,11 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
226 - **`src/workspace.rs`** — the directories the session treats as project roots. An editor can
227 open several at once (Zed calls it a multi-root project) and ACP carries the extras as
228 `additional_directories` on every session request; the headless CLI takes them as repeatable
229 - `--add-dir` flags. The process still has one working directory, so the extra roots live in a
229 + `--add-dir` flags. A client only sends those extras to an agent that advertises
230 + `sessionCapabilities.additionalDirectories` in its `initialize` reply, so that capability in
231 + `handle_initialize` is what makes the rest of this reachable — without it Zed keeps the first
232 + root, drops the others, and shows "This agent doesn't currently support multi-root workspaces".
233 + The process still has one working directory, so the extra roots live in a
234 process-global here and `project_dirs()` returns cwd-first, extras after. Project-local
235 discovery reads it: skills, slash commands, subagent types, and instruction files all scan
236 every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
.agents/skills/agent-client-protocol/SKILL.md
+8 -2
@@ -272,7 +272,8 @@ per-session picker (see Config options below).
272
273 The session requests carry `cwd: PathBuf` and (with the feature)
274 `additional_directories: Vec<PathBuf>` — the other directories of a multi-root
275 -project. `SiGitAgent::enter_session_roots` stashes `cwd`, `set_current_dir`s to
275 +project, sent only to an agent whose `initialize` reply carries
276 +`SessionCapabilities::new().additional_directories(SessionAdditionalDirectoriesCapabilities::new())`. `SiGitAgent::enter_session_roots` stashes `cwd`, `set_current_dir`s to
277 it, hands the extras to `workspace::set_additional_roots`, and returns the ones
278 that survived (a root that isn't a directory, repeats, or just names `cwd` again
279 is dropped). `session_context_message` then names every root in the system
@@ -563,7 +564,12 @@ Editor Agent
564 11. **`additional_directories` is not decoration** — dropping it means a
565 multi-root project silently behaves as if only the first root existed, with
566 the other repositories' `AGENTS.md` and skills missing. Route it through
566 - `workspace.rs`, not just the log line.
567 + `workspace.rs`, not just the log line. Handling it is also only half the
568 + job: Zed gates the field on
569 + `agentCapabilities.sessionCapabilities.additionalDirectories` being present
570 + in the `initialize` reply, so an agent that reads the field but never
571 + advertises the capability is never sent one, and the user gets a banner
572 + saying the agent has no multi-root support.
573 12. **Store `SessionId` as `SessionId`**, not `String`, so `==` is clean.
574 13. **`SetSessionConfigOptionResponse::new(config_options)`** — the response
575 carries the *rebuilt* options so the picker reflects the new current value.
CHANGELOG.md
+5 -2
@@ -6,8 +6,11 @@
6
7 - **Multi-root projects now work in the editor.** When a client opens several
8 directories in one project, ACP sends the extra ones as
9 - `additionalDirectories`; siGit used to log them and move on, so everything
10 - outside the first root was invisible. All the roots are now part of the
9 + `additionalDirectories` — but only to an agent that says it wants them, which
10 + siGit never did, so Zed kept the first root and showed a banner saying
11 + multi-root workspaces were unsupported. The capability is now advertised at
12 + `initialize`, and the directories that arrive are no longer logged and thrown
13 + away. All the roots are now part of the
14 session: the model is told about each one, every root's `AGENTS.md` /
15 `CLAUDE.md` is loaded, and skills, slash commands, and subagent types are
16 discovered from all of them (primary root first, so it still wins name
src/instructions.rs
+6 -2
@@ -22,6 +22,8 @@
22
23 use std::path::{Path, PathBuf};
24
25 +use crate::workspace;
26 +
27 /// Instruction file names to look for in each directory, in priority order.
28 /// Only the first match in a given directory is loaded.
29 const INSTRUCTION_FILE_NAMES: &[&str] = &["AGENTS.md", "CLAUDE.md"];
@@ -84,8 +86,10 @@ pub fn load_workspace_instructions(cwd: &Path, additional_roots: &[PathBuf]) ->
86 };
87
88 // Dedup by canonical path so the same file reached via two roots (or a
87 - // symlink) is only loaded once.
88 - let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
89 + // symlink) is only loaded once. Two roots that each carry their own
90 + // AGENTS.md still contribute both: the paths differ, and each one is
91 + // the instructions for its own repository.
92 + let canonical = workspace::canonical_key(&path);
93 if seen.contains(&canonical) {
94 continue;
95 }
src/main.rs
+11 -5
@@ -75,9 +75,9 @@ use agent_client_protocol::schema::v1::{
75 ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
76 LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
77 PermissionOptionKind, PromptRequest, PromptResponse, RequestPermissionOutcome,
78 - RequestPermissionRequest, SessionCapabilities, SessionConfigOption,
79 - SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
80 - SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
78 + RequestPermissionRequest, SessionAdditionalDirectoriesCapabilities, SessionCapabilities,
79 + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
80 + SessionConfigValueId, SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
81 SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
82 ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
83 };
@@ -1093,8 +1093,14 @@ impl SiGitAgent {
1093 AgentCapabilities::default()
1094 .load_session(true)
1095 .session_capabilities(
1096 - SessionCapabilities::new().fork(SessionForkCapabilities::new()),
1097 - ),
1096 + SessionCapabilities::new()
1097 + .fork(SessionForkCapabilities::new())
1098 + // Without this, a client that has several directories
1099 + // open never sends the extra ones: Zed drops every root
1100 + // but the first and tells the user the agent has no
1101 + // multi-root support. See `workspace.rs`.
1102 + .additional_directories(SessionAdditionalDirectoriesCapabilities::new()),
1103 + ),
1104 )
1105 .meta(initialize_meta()))
1106 }
src/workspace.rs
+53 -5
@@ -13,7 +13,7 @@
13 //! startup (`mcp::init`), before any session exists, so a second root's
14 //! `.sigit/mcp.toml` has nobody to tell.
15
16 -use std::path::{Path, PathBuf};
16 +use std::path::{Component, Path, PathBuf};
17 use std::sync::RwLock;
18
19 /// The session's roots beyond `cwd`. Empty for an ordinary single-root project.
@@ -25,7 +25,7 @@ static ADDITIONAL_ROOTS: RwLock<Vec<PathBuf>> = RwLock::new(Vec::new());
25 /// dropped — an editor is free to send any of those, and every consumer here
26 /// would either scan nothing or do the same work twice.
27 pub fn set_additional_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
28 - let mut seen: Vec<PathBuf> = vec![canonical(cwd)];
28 + let mut seen: Vec<PathBuf> = vec![canonical_key(cwd)];
29 let mut kept: Vec<PathBuf> = Vec::new();
30
31 for root in roots {
@@ -36,7 +36,7 @@ pub fn set_additional_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
36 );
37 continue;
38 }
39 - let key = canonical(root);
39 + let key = canonical_key(root);
40 if seen.contains(&key) {
41 continue;
42 }
@@ -70,8 +70,42 @@ pub fn project_dirs() -> Vec<PathBuf> {
70 dirs
71 }
72
73 -fn canonical(path: &Path) -> PathBuf {
74 - path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
73 +/// A comparison key for a path: its canonical form when the filesystem can
74 +/// give one, and otherwise the path made absolute and lexically normalized.
75 +///
76 +/// The fallback matters. `canonicalize` fails on a path the process cannot
77 +/// resolve (an unreadable parent, a root that vanished between the client
78 +/// sending it and us reading it), and returning the path untouched there would
79 +/// let `.` and `..` segments or a relative spelling of a root that is already
80 +/// in the list read as a new one.
81 +pub fn canonical_key(path: &Path) -> PathBuf {
82 + path.canonicalize().unwrap_or_else(|_| normalize(path))
83 +}
84 +
85 +/// Absolute + lexically normalized: `.` dropped, `..` folded into the previous
86 +/// segment. Purely textual, so it never touches the filesystem.
87 +fn normalize(path: &Path) -> PathBuf {
88 + let absolute = if path.is_absolute() {
89 + path.to_path_buf()
90 + } else {
91 + std::env::current_dir()
92 + .map(|cwd| cwd.join(path))
93 + .unwrap_or_else(|_| path.to_path_buf())
94 + };
95 +
96 + let mut out = PathBuf::new();
97 + for component in absolute.components() {
98 + match component {
99 + Component::CurDir => {}
100 + Component::ParentDir => {
101 + if !out.pop() {
102 + out.push(component);
103 + }
104 + }
105 + other => out.push(other),
106 + }
107 + }
108 + out
109 }
110
111 #[cfg(test)]
@@ -109,4 +143,18 @@ mod tests {
143 assert!(additional_roots().is_empty());
144 std::fs::remove_dir_all(&temp).ok();
145 }
146 +
147 + #[test]
148 + fn keys_a_path_the_filesystem_cannot_resolve() {
149 + // Nothing here exists, so `canonicalize` fails on every one of them and
150 + // the lexical fallback is what has to make the three agree.
151 + let base = std::env::temp_dir().join("sigit-ws-missing");
152 + let direct = canonical_key(&base.join("root"));
153 + let dotted = canonical_key(&base.join(".").join("root"));
154 + let backtracked = canonical_key(&base.join("other").join("..").join("root"));
155 +
156 + assert_eq!(direct, dotted);
157 + assert_eq!(direct, backtracked);
158 + assert!(direct.is_absolute());
159 + }
160 }
tests/acp_multi_root.rs
+10 -1
@@ -199,7 +199,16 @@ fn a_skill_from_a_second_root_is_offered_to_the_model() {
199 "initialize",
200 json!({"protocolVersion": 1, "clientCapabilities": {}}),
201 );
202 - agent.wait_for_response(id);
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",