48
mod skills;
49
mod subagents;
50
mod tools;
51
+mod workspace;
52
53
/// Serializes tests that mutate process-global state: env vars
54
/// (`SIGIT_CONFIG_DIR` etc.) and the current directory. `cargo test` runs
352
`/login <email> <password>` (or the Authenticate button), then pick the tier again. \
353
Create an account at https://sigit.si.";
354
354
-/// The per-session context system message: cwd guidance plus any project
355
-/// instruction files (`AGENTS.md` / `CLAUDE.md`) found for that directory. Used
356
-/// by every session entry point so on-device and cloud backends get the same
357
-/// always-on project context.
358
-fn session_context_message(cwd: &std::path::Path) -> String {
359
- let mut message = format!(
360
- "The user's project working directory is {}. \
361
- Always use absolute paths under this directory for all file \
362
- and directory operations. This is the root of the project \
363
- the user has open in their editor.",
364
- cwd.display()
365
- );
366
- if let Some(project_instructions) = instructions::load_project_instructions(cwd) {
355
+/// The per-session context system message: root-directory guidance plus any
356
+/// project instruction files (`AGENTS.md` / `CLAUDE.md`) found for those roots.
357
+/// Used by every session entry point so on-device and cloud backends get the
358
+/// same always-on project context.
359
+///
360
+/// `additional_roots` is empty for an ordinary project and non-empty for a
361
+/// multi-root one, where the editor opened several directories at once (see
362
+/// `workspace.rs`).
363
+fn session_context_message(cwd: &std::path::Path, additional_roots: &[PathBuf]) -> String {
364
+ let mut message = if additional_roots.is_empty() {
365
+ format!(
366
+ "The user's project working directory is {}. \
367
+ Always use absolute paths under this directory for all file \
368
+ and directory operations. This is the root of the project \
369
+ the user has open in their editor.",
370
+ cwd.display()
371
+ )
372
+ } else {
373
+ let mut roots = format!("- {} (primary)", cwd.display());
374
+ for root in additional_roots {
375
+ roots.push_str(&format!("\n- {}", root.display()));
376
+ }
377
+ format!(
378
+ "The user has a multi-root project open in their editor, with these \
379
+ root directories:\n{roots}\n\
380
+ All of them are in scope: read, search and edit files in any of them. \
381
+ Always use absolute paths for file and directory operations, since a \
382
+ relative path resolves against the primary root only. When the user \
383
+ names a file without saying which root it is in, look for it in each \
384
+ root before asking."
385
+ )
386
+ };
387
+ if let Some(project_instructions) =
388
+ instructions::load_workspace_instructions(cwd, additional_roots)
389
+ {
390
message.push_str("\n\n");
391
message.push_str(&project_instructions);
392
}
1051
if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1052
self.engine
1053
.push_history(onde::inference::ChatMessage::system(
1031
- session_context_message(&cwd),
1054
+ session_context_message(&cwd, &workspace::additional_roots()),
1055
))
1056
.await;
1057
}
1146
}
1147
}
1148
1149
+ /// Record the session's roots and move the process into the primary one.
1150
+ ///
1151
+ /// The editor sends `cwd` plus, for a multi-root project, the other
1152
+ /// directories it has open. Tool calls may use relative paths, so the
1153
+ /// process follows `cwd`; the extra roots go to `workspace`, which is where
1154
+ /// project-local discovery picks them up. Returns the roots that were kept.
1155
+ fn enter_session_roots(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> Vec<PathBuf> {
1156
+ if let Ok(mut guard) = self.session_cwd.lock() {
1157
+ *guard = Some(cwd.to_path_buf());
1158
+ }
1159
+ let additional_roots = workspace::set_additional_roots(cwd, additional);
1160
+ if cwd.is_dir()
1161
+ && let Err(err) = std::env::set_current_dir(cwd)
1162
+ {
1163
+ log::warn!("could not set cwd to {}: {err}", cwd.display());
1164
+ }
1165
+ additional_roots
1166
+ }
1167
+
1168
async fn handle_load_session(
1169
&self,
1170
cx: &ConnectionTo<Client>,
1180
.collect::<Vec<_>>()
1181
);
1182
1141
- if let Ok(mut guard) = self.session_cwd.lock() {
1142
- *guard = Some(args.cwd.clone());
1143
- }
1183
+ let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1184
1185
// A session boundary: grants and plan mode from the previous life of
1186
// this session id must not carry over — and since one shared engine
1187
// means one live conversation, state for every other id is dead too.
1188
permissions::reset_all();
1189
1150
- // tool calls use relative paths, so we need to match the editor's cwd
1151
- if args.cwd.is_dir()
1152
- && let Err(err) = std::env::set_current_dir(&args.cwd)
1153
- {
1154
- log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1155
- }
1156
-
1190
// start from a clean slate; a stored session (below) replaces it
1191
self.engine.clear_history().await;
1192
1193
self.engine
1194
.push_history(onde::inference::ChatMessage::system(
1162
- session_context_message(&args.cwd),
1195
+ session_context_message(&args.cwd, &additional_roots),
1196
))
1197
.await;
1198
1248
.collect::<Vec<_>>()
1249
);
1250
1218
- if let Ok(mut guard) = self.session_cwd.lock() {
1219
- *guard = Some(args.cwd.clone());
1220
- }
1221
- if args.cwd.is_dir()
1222
- && let Err(err) = std::env::set_current_dir(&args.cwd)
1223
- {
1224
- log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1225
- }
1251
+ let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1252
1253
// no persistence, so fork == fresh session
1254
self.engine.clear_history().await;
1255
1256
self.engine
1257
.push_history(onde::inference::ChatMessage::system(
1232
- session_context_message(&args.cwd),
1258
+ session_context_message(&args.cwd, &additional_roots),
1259
))
1260
.await;
1261
1296
.collect::<Vec<_>>()
1297
);
1298
1273
- if let Ok(mut guard) = self.session_cwd.lock() {
1274
- *guard = Some(args.cwd.clone());
1275
- }
1276
- if args.cwd.is_dir()
1277
- && let Err(err) = std::env::set_current_dir(&args.cwd)
1278
- {
1279
- log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1280
- }
1299
+ let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1300
1301
self.engine.clear_history().await;
1302
1303
self.engine
1304
.push_history(onde::inference::ChatMessage::system(
1286
- session_context_message(&args.cwd),
1305
+ session_context_message(&args.cwd, &additional_roots),
1306
))
1307
.await;
1308
1897
// gets at session load, so the cloud model shares the same project context.
1898
if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1899
system_prompt.push_str("\n\n");
1881
- system_prompt.push_str(&session_context_message(&cwd));
1900
+ system_prompt.push_str(&session_context_message(
1901
+ &cwd,
1902
+ &workspace::additional_roots(),
1903
+ ));
1904
}
1905
let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
1906
cfg.base_url,
2784
let info = agent.engine.info().await;
2785
let model = info.model_name.as_deref().unwrap_or("(none)");
2786
let memory = info.approx_memory.as_deref().unwrap_or("unknown");
2765
- agent
2766
- .send_assistant_message(
2767
- cx,
2768
- session_id,
2769
- format!(
2770
- "status: {:?} model: {} memory: {} history: {} turns",
2771
- info.status, model, memory, info.history_length,
2772
- ),
2773
- )
2774
- .ok();
2787
+ let mut report = format!(
2788
+ "status: {:?} model: {} memory: {} history: {} turns",
2789
+ info.status, model, memory, info.history_length,
2790
+ );
2791
+ // Only worth a line when the project has more than one root —
2792
+ // otherwise it just repeats what the editor already shows.
2793
+ let extra_roots = workspace::additional_roots();
2794
+ if !extra_roots.is_empty() {
2795
+ let cwd = agent
2796
+ .session_cwd
2797
+ .lock()
2798
+ .ok()
2799
+ .and_then(|guard| guard.clone())
2800
+ .unwrap_or_else(|| PathBuf::from("."));
2801
+ report.push_str(&format!("\nproject roots: {} (primary)", cwd.display()));
2802
+ for root in &extra_roots {
2803
+ report.push_str(&format!(", {}", root.display()));
2804
+ }
2805
+ }
2806
+ agent.send_assistant_message(cx, session_id, report).ok();
2807
}
2808
SlashCommand::Models(None) => {
2809
let current_model = agent.current_model.lock().unwrap().clone();
3651
eprintln!("sigit: cannot use --cwd {}: {error}", dir.display());
3652
std::process::exit(2);
3653
}
3654
+ // --add-dir is the headless form of a multi-root project: extra
3655
+ // roots that project-local discovery scans alongside the cwd.
3656
+ if !config.add_dirs.is_empty() {
3657
+ for dir in &config.add_dirs {
3658
+ if !dir.is_dir() {
3659
+ eprintln!(
3660
+ "sigit: cannot use --add-dir {}: not a directory",
3661
+ dir.display()
3662
+ );
3663
+ std::process::exit(2);
3664
+ }
3665
+ }
3666
+ let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3667
+ workspace::set_additional_roots(&cwd, &config.add_dirs);
3668
+ }
3669
setup::setup_shared_model_cache();
3670
// Best-effort MCP discovery (incl. the official server), like the
3671
// other entry points, so MCP tools are offered to the model.
3716
mod tests {
3717
use super::*;
3718
3719
+ #[test]
3720
+ fn session_context_message_lists_every_root_of_a_multi_root_project() {
3721
+ let primary = PathBuf::from("/tmp/primary");
3722
+ let secondary = PathBuf::from("/tmp/secondary");
3723
+
3724
+ let single = session_context_message(&primary, &[]);
3725
+ assert!(single.contains("/tmp/primary"));
3726
+ assert!(!single.contains("multi-root"));
3727
+
3728
+ let multi = session_context_message(&primary, &[secondary]);
3729
+ assert!(multi.contains("multi-root"));
3730
+ assert!(multi.contains("/tmp/primary (primary)"));
3731
+ assert!(multi.contains("/tmp/secondary"));
3732
+ // Relative paths only resolve against the primary root, so the model is
3733
+ // told to address the others absolutely.
3734
+ assert!(multi.contains("absolute paths"));
3735
+ }
3736
+
3737
#[test]
3738
fn streamed_reply_sends_each_fragment_as_it_is_revealed() {
3739
let mut reply = StreamedReply::default();