Support session working directory and resource loading
Capture the project working directory from the editor and set it as the process cwd for each session. Update prompt handling to read file contents for ResourceLinks using the session cwd.
paydii committed
Apr 25, 2026 at 00:40 UTC
cdc720e14099e1d66b4bb26bfeb67adb1c33f5dc
2 files changed
+246
-14
Cargo.toml
+1
-1
@@ -18,7 +18,7 @@ path = "src/main.rs"
18
19
[dependencies]
20
# ACP protocol SDK
21
-agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork"] }
21
+agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork", "unstable_session_additional_directories"] }
22
23
# Onde Inference engine (local LLM)
24
onde = { path = "../onde" }
src/main.rs
+245
-13
@@ -62,6 +62,7 @@ use agent_client_protocol::{
62
};
63
use futures::future::LocalBoxFuture;
64
use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult};
65
+use std::path::PathBuf;
66
use tokio::sync::mpsc;
67
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
68
use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
@@ -200,6 +201,10 @@ fn agent_tools_as_onde() -> Vec<ToolDefinition> {
201
struct SiGitAgent {
202
engine: Arc<ChatEngine>,
203
notification_tx: mpsc::Sender<SessionNotification>,
204
+ /// The project working directory provided by the editor via ACP session
205
+ /// creation. Tool calls use this as `cwd` so file operations target the
206
+ /// correct project, not wherever the agent process was spawned.
207
+ session_cwd: std::sync::Mutex<Option<PathBuf>>,
208
}
209
210
impl SiGitAgent {
@@ -207,8 +212,18 @@ impl SiGitAgent {
212
Self {
213
engine,
214
notification_tx,
215
+ session_cwd: std::sync::Mutex::new(None),
216
}
217
}
218
+
219
+ /// Return the session working directory, falling back to the process cwd.
220
+ fn cwd(&self) -> PathBuf {
221
+ self.session_cwd
222
+ .lock()
223
+ .ok()
224
+ .and_then(|guard| guard.clone())
225
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
226
+ }
227
}
228
229
#[async_trait::async_trait(?Send)]
@@ -248,12 +263,44 @@ impl Agent for SiGitAgent {
263
&self,
264
args: LoadSessionRequest,
265
) -> agent_client_protocol::Result<LoadSessionResponse> {
251
- log::info!("load_session: id={}", args.session_id);
266
+ log::info!(
267
+ "load_session: id={}, cwd={}, additional_directories={:?}",
268
+ args.session_id,
269
+ args.cwd.display(),
270
+ args.additional_directories
271
+ .iter()
272
+ .map(|p| p.display().to_string())
273
+ .collect::<Vec<_>>()
274
+ );
275
+
276
+ // Capture the project working directory from the editor.
277
+ if let Ok(mut guard) = self.session_cwd.lock() {
278
+ *guard = Some(args.cwd.clone());
279
+ }
280
+
281
+ // Set the process cwd so tool calls using relative paths land in the
282
+ // correct project directory.
283
+ if args.cwd.is_dir() {
284
+ if let Err(err) = std::env::set_current_dir(&args.cwd) {
285
+ log::warn!("could not set cwd to {}: {err}", args.cwd.display());
286
+ }
287
+ }
288
289
// Clear conversation history — siGit doesn't persist sessions, so a
290
// "load" is effectively a fresh start with the same session ID.
291
self.engine.clear_history().await;
292
293
+ // Tell the model which project directory it's working in.
294
+ self.engine
295
+ .push_history(onde::inference::ChatMessage::system(format!(
296
+ "The user's project working directory is {}. \
297
+ Always use absolute paths under this directory for all file \
298
+ and directory operations. This is the root of the project \
299
+ the user has open in their editor.",
300
+ args.cwd.display()
301
+ )))
302
+ .await;
303
+
304
Ok(LoadSessionResponse::new())
305
}
306
@@ -262,41 +309,226 @@ impl Agent for SiGitAgent {
309
args: ForkSessionRequest,
310
) -> agent_client_protocol::Result<ForkSessionResponse> {
311
let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
265
- log::info!("fork_session: from={} new={new_id}", args.session_id);
312
+ log::info!(
313
+ "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
314
+ args.session_id,
315
+ args.cwd.display(),
316
+ args.additional_directories
317
+ .iter()
318
+ .map(|p| p.display().to_string())
319
+ .collect::<Vec<_>>()
320
+ );
321
+
322
+ // Update cwd if the fork provides a different one.
323
+ if let Ok(mut guard) = self.session_cwd.lock() {
324
+ *guard = Some(args.cwd.clone());
325
+ }
326
+ if args.cwd.is_dir() {
327
+ if let Err(err) = std::env::set_current_dir(&args.cwd) {
328
+ log::warn!("could not set cwd to {}: {err}", args.cwd.display());
329
+ }
330
+ }
331
332
// siGit doesn't persist history, so a fork is effectively a fresh
333
// session — clear the conversation and let the user start over from
334
// their edited message.
335
self.engine.clear_history().await;
336
337
+ self.engine
338
+ .push_history(onde::inference::ChatMessage::system(format!(
339
+ "The user's project working directory is {}. \
340
+ Always use absolute paths under this directory for all file \
341
+ and directory operations. This is the root of the project \
342
+ the user has open in their editor.",
343
+ args.cwd.display()
344
+ )))
345
+ .await;
346
+
347
Ok(ForkSessionResponse::new(new_id))
348
}
349
350
async fn new_session(
351
&self,
277
- _args: NewSessionRequest,
352
+ args: NewSessionRequest,
353
) -> agent_client_protocol::Result<NewSessionResponse> {
354
let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
280
- log::info!("new_session: id={session_id}");
355
+ log::info!(
356
+ "new_session: id={session_id}, cwd={}, additional_directories={:?}",
357
+ args.cwd.display(),
358
+ args.additional_directories
359
+ .iter()
360
+ .map(|p| p.display().to_string())
361
+ .collect::<Vec<_>>()
362
+ );
363
+
364
+ // Capture the project working directory from the editor.
365
+ if let Ok(mut guard) = self.session_cwd.lock() {
366
+ *guard = Some(args.cwd.clone());
367
+ }
368
+ if args.cwd.is_dir() {
369
+ if let Err(err) = std::env::set_current_dir(&args.cwd) {
370
+ log::warn!("could not set cwd to {}: {err}", args.cwd.display());
371
+ }
372
+ }
373
374
// Clear history — the model is already loaded.
375
self.engine.clear_history().await;
376
377
+ self.engine
378
+ .push_history(onde::inference::ChatMessage::system(format!(
379
+ "The user's project working directory is {}. \
380
+ Always use absolute paths under this directory for all file \
381
+ and directory operations. This is the root of the project \
382
+ the user has open in their editor.",
383
+ args.cwd.display()
384
+ )))
385
+ .await;
386
+
387
Ok(NewSessionResponse::new(session_id))
388
}
389
390
async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> {
391
let session_id = args.session_id.clone();
392
291
- let user_text: String = args
292
- .prompt
293
- .iter()
294
- .filter_map(|block| match block {
295
- ContentBlock::Text(t) => Some(t.text.as_str()),
296
- _ => None,
297
- })
298
- .collect::<Vec<_>>()
299
- .join("\n");
393
+ // Debug: log every content block the editor sends so we can see
394
+ // exactly what arrives for @ references, file context, etc.
395
+ for (i, block) in args.prompt.iter().enumerate() {
396
+ match block {
397
+ ContentBlock::Text(t) => {
398
+ log::info!(
399
+ "prompt({}) block[{}]: Text({} chars) = \"{}\"",
400
+ session_id,
401
+ i,
402
+ t.text.len(),
403
+ t.text.chars().take(200).collect::<String>()
404
+ );
405
+ }
406
+ ContentBlock::Resource(embedded) => {
407
+ log::info!(
408
+ "prompt({}) block[{}]: EmbeddedResource = {:?}",
409
+ session_id,
410
+ i,
411
+ match &embedded.resource {
412
+ agent_client_protocol::EmbeddedResourceResource::TextResourceContents(t) =>
413
+ format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()),
414
+ agent_client_protocol::EmbeddedResourceResource::BlobResourceContents(b) =>
415
+ format!("BlobResource(uri={})", b.uri),
416
+ _ => "Unknown".to_string(),
417
+ }
418
+ );
419
+ }
420
+ ContentBlock::ResourceLink(link) => {
421
+ log::info!(
422
+ "prompt({}) block[{}]: ResourceLink(name={}, uri={}, title={:?}, desc={:?})",
423
+ session_id,
424
+ i,
425
+ link.name,
426
+ link.uri,
427
+ link.title,
428
+ link.description
429
+ );
430
+ }
431
+ other => {
432
+ log::info!(
433
+ "prompt({}) block[{}]: Other({:?})",
434
+ session_id,
435
+ i,
436
+ std::mem::discriminant(other)
437
+ );
438
+ }
439
+ }
440
+ }
441
+
442
+ let mut parts: Vec<String> = Vec::new();
443
+
444
+ for block in &args.prompt {
445
+ match block {
446
+ ContentBlock::Text(t) => {
447
+ parts.push(t.text.clone());
448
+ }
449
+ ContentBlock::Resource(embedded) => {
450
+ // Embedded file content sent by the editor (preferred over ResourceLink).
451
+ match &embedded.resource {
452
+ agent_client_protocol::EmbeddedResourceResource::TextResourceContents(
453
+ text_resource,
454
+ ) => {
455
+ parts.push(format!(
456
+ "\n--- {} ---\n{}\n--- end {} ---",
457
+ text_resource.uri, text_resource.text, text_resource.uri
458
+ ));
459
+ }
460
+ agent_client_protocol::EmbeddedResourceResource::BlobResourceContents(
461
+ blob,
462
+ ) => {
463
+ parts.push(format!("[binary resource: {}]", blob.uri));
464
+ }
465
+ _ => {
466
+ log::debug!("ignoring unsupported embedded resource variant");
467
+ }
468
+ }
469
+ }
470
+ ContentBlock::ResourceLink(link) => {
471
+ // The editor sent a reference but not the content — read it if it's a file.
472
+ let label = link.name.clone();
473
+
474
+ if let Some(raw_path) = link.uri.strip_prefix("file://") {
475
+ // Split off the #L<start>:<end> fragment if present.
476
+ let (file_path, line_range) = if let Some(hash_pos) = raw_path.rfind('#') {
477
+ let fragment = &raw_path[hash_pos + 1..];
478
+ let path = &raw_path[..hash_pos];
479
+ // Parse "L207:219" or "L207-219" → (207, 219)
480
+ let range = fragment.strip_prefix('L').and_then(|rest| {
481
+ let sep = if rest.contains(':') { ':' } else { '-' };
482
+ let mut parts = rest.splitn(2, sep);
483
+ let start = parts.next()?.parse::<usize>().ok()?;
484
+ let end = parts.next()?.parse::<usize>().ok()?;
485
+ Some((start, end))
486
+ });
487
+ (path, range)
488
+ } else {
489
+ (raw_path, None)
490
+ };
491
+
492
+ match std::fs::read_to_string(file_path) {
493
+ Ok(contents) => {
494
+ let extracted = if let Some((start, end)) = line_range {
495
+ // Extract only the requested line range (1-based, inclusive).
496
+ let selected: Vec<&str> = contents
497
+ .lines()
498
+ .enumerate()
499
+ .filter(|(i, _)| {
500
+ let line_num = i + 1;
501
+ line_num >= start && line_num <= end
502
+ })
503
+ .map(|(_, line)| line)
504
+ .collect();
505
+ format!(
506
+ "\n--- {label} ({file_path} lines {start}-{end}) ---\n{}\n--- end {label} ---",
507
+ selected.join("\n")
508
+ )
509
+ } else {
510
+ format!(
511
+ "\n--- {label} ({file_path}) ---\n{contents}\n--- end {label} ---"
512
+ )
513
+ };
514
+ parts.push(extracted);
515
+ }
516
+ Err(err) => {
517
+ log::warn!("could not read ResourceLink {}: {err}", link.uri);
518
+ parts.push(format!("[referenced file: {label} ({file_path})]"));
519
+ }
520
+ }
521
+ } else {
522
+ parts.push(format!("[resource link: {label} ({})]", link.uri));
523
+ }
524
+ }
525
+ _ => {
526
+ log::debug!("ignoring unsupported content block type in prompt");
527
+ }
528
+ }
529
+ }
530
+
531
+ let user_text = parts.join("\n");
532
533
if user_text.trim().is_empty() {
534
return Ok(PromptResponse::new(StopReason::EndTurn));