Update ACP SDK to v0.11
Seto Elkahfi committed
Jun 9, 2026 at 12:43 UTC
c7c34ff30c4e273c8388171f93a36f2622a31999
3 files changed
+676
-587
Cargo.lock
-1
@@ -5071,7 +5071,6 @@ version = "1.0.4"
5071
dependencies = [
5072
"agent-client-protocol",
5073
"anyhow",
5074
- "async-trait",
5074
"crossterm 0.29.0",
5075
"futures",
5076
"libc",
Cargo.toml
-1
@@ -25,7 +25,6 @@ agent-client-protocol = { version = "0.11", features = ["unstable_session_fork",
25
onde = "1.1.2"
26
27
# Async runtime
28
-async-trait = "0.1"
28
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] }
29
tokio-util = { version = "0.7", features = ["compat"] }
30
futures = "0.3"
src/main.rs
+676
-585
@@ -11,10 +11,6 @@
11
//! Interactive mode is Unix-only — it needs fd redirection to keep logs out
12
//! of the TUI. Windows only gets ACP mode for now.
13
//!
14
-//! The model loads before the ACP `LocalSet` starts because `mistralrs` calls
15
-//! `block_in_place`, which panics inside `spawn_local`. Loading on a regular
16
-//! multi-thread worker sidesteps that.
17
-//!
14
//! On macOS the HF cache lives in the App Group container shared with the
15
//! siGit desktop app. See [`setup`].
16
//!
@@ -44,22 +40,21 @@ use std::sync::Arc;
40
41
use onde::inference::SamplingConfig;
42
47
-use agent_client_protocol::{
48
- Agent, AgentCapabilities, AgentSideConnection, AuthMethod, AuthMethodAgent,
49
- AuthenticateRequest, AuthenticateResponse, CancelNotification, Client, ContentBlock,
50
- ContentChunk, ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest,
51
- InitializeResponse, LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest,
52
- NewSessionResponse, PromptRequest, PromptResponse, ProtocolVersion, SessionCapabilities,
53
- SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
54
- SessionConfigValueId, SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
43
+use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
44
+use agent_client_protocol::schema::{
45
+ AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
46
+ CancelNotification, ContentBlock, ContentChunk, EmbeddedResourceResource,
47
+ ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse,
48
+ LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse,
49
+ PromptRequest, PromptResponse, ProtocolVersion, SessionCapabilities, SessionConfigOption,
50
+ SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
51
+ SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
52
SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
53
ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
54
};
58
-use futures::future::LocalBoxFuture;
55
use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult};
56
use std::path::PathBuf;
57
use std::sync::atomic::{AtomicBool, Ordering};
62
-use tokio::sync::mpsc;
58
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
59
use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
60
@@ -267,7 +262,6 @@ fn initialize_meta() -> Meta {
262
263
struct SiGitAgent {
264
engine: Arc<ChatEngine>,
270
- notification_tx: mpsc::Sender<SessionNotification>,
265
/// cwd from the editor — tool calls run here, not where the process started
266
session_cwd: std::sync::Mutex<Option<PathBuf>>,
267
current_model: std::sync::Mutex<GgufModelConfig>,
@@ -288,7 +282,6 @@ struct SiGitAgent {
282
impl SiGitAgent {
283
fn new(
284
engine: Arc<ChatEngine>,
291
- notification_tx: mpsc::Sender<SessionNotification>,
285
initial_model: GgufModelConfig,
286
model_ready: Arc<AtomicBool>,
287
startup_model_load_started: Arc<AtomicBool>,
@@ -299,7 +292,6 @@ impl SiGitAgent {
292
let startup_model_id = initial_model.model_id.clone();
293
Self {
294
engine,
302
- notification_tx,
295
session_cwd: std::sync::Mutex::new(None),
296
current_model: std::sync::Mutex::new(initial_model),
297
model_ready,
@@ -372,7 +364,11 @@ impl SiGitAgent {
364
}
365
366
/// block until the startup model is ready, showing progress in the session.
375
- async fn await_model_ready(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> {
367
+ async fn await_model_ready(
368
+ &self,
369
+ cx: &ConnectionTo<Client>,
370
+ session_id: &SessionId,
371
+ ) -> agent_client_protocol::Result<()> {
372
if self.model_ready.load(Ordering::Acquire) {
373
// already done — might be a stored error from earlier
374
if let Some(err) = self.model_load_error.lock().unwrap().as_ref() {
@@ -394,6 +390,7 @@ impl SiGitAgent {
390
};
391
392
self.send_tool_call_update(
393
+ cx,
394
session_id.clone(),
395
SessionUpdate::ToolCall(
396
ToolCall::new(tool_call_id.clone(), &title)
@@ -402,7 +399,7 @@ impl SiGitAgent {
399
.content(vec![format!("{}…", title).into()]),
400
),
401
)
405
- .await;
402
+ .ok();
403
404
let expected_bytes = if self.startup_needs_download {
405
onde::inference::models::SUPPORTED_MODEL_INFO
@@ -484,6 +481,7 @@ impl SiGitAgent {
481
};
482
483
self.send_tool_call_update(
484
+ cx,
485
session_id.clone(),
486
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
487
tool_call_id.clone(),
@@ -493,13 +491,14 @@ impl SiGitAgent {
491
.content(vec![update_content.into()]),
492
)),
493
)
496
- .await;
494
+ .ok();
495
}
496
497
// done — check if it blew up
498
let load_error = self.model_load_error.lock().unwrap().clone();
499
if let Some(err) = load_error {
500
self.send_tool_call_update(
501
+ cx,
502
session_id.clone(),
503
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
504
tool_call_id,
@@ -509,7 +508,7 @@ impl SiGitAgent {
508
.content(vec![format!("error: {err}").into()]),
509
)),
510
)
512
- .await;
511
+ .ok();
512
513
return Err(agent_client_protocol::Error::new(
514
-32603,
@@ -524,6 +523,7 @@ impl SiGitAgent {
523
};
524
525
self.send_tool_call_update(
526
+ cx,
527
session_id.clone(),
528
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
529
tool_call_id,
@@ -532,26 +532,30 @@ impl SiGitAgent {
532
.status(ToolCallStatus::Completed),
533
)),
534
)
535
- .await;
535
+ .ok();
536
537
Ok(())
538
}
539
540
- async fn send_assistant_message(&self, session_id: SessionId, text: impl Into<String>) {
541
- let notification = SessionNotification::new(
540
+ fn send_assistant_message(
541
+ &self,
542
+ cx: &ConnectionTo<Client>,
543
+ session_id: SessionId,
544
+ text: impl Into<String>,
545
+ ) -> agent_client_protocol::Result<()> {
546
+ cx.send_notification(SessionNotification::new(
547
session_id,
548
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.into()))),
544
- );
545
- if self.notification_tx.send(notification).await.is_err() {
546
- log::warn!("notification channel closed");
547
- }
549
+ ))
550
}
551
550
- async fn send_tool_call_update(&self, session_id: SessionId, update: SessionUpdate) {
551
- let notification = SessionNotification::new(session_id, update);
552
- if self.notification_tx.send(notification).await.is_err() {
553
- log::warn!("notification channel closed");
554
- }
552
+ fn send_tool_call_update(
553
+ &self,
554
+ cx: &ConnectionTo<Client>,
555
+ session_id: SessionId,
556
+ update: SessionUpdate,
557
+ ) -> agent_client_protocol::Result<()> {
558
+ cx.send_notification(SessionNotification::new(session_id, update))
559
}
560
561
async fn switch_model_by_id(
@@ -647,439 +651,145 @@ impl SiGitAgent {
651
}
652
}
653
650
-/// config option ID for the model picker in Zed's agent panel
651
-const MODEL_CONFIG_ID: &str = "sigit-model";
654
+// ── ACP handler implementations ───────────────────────────────────────────────
655
653
-fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
654
- let items = models::build_model_picker_items();
656
+impl SiGitAgent {
657
+ async fn handle_initialize(
658
+ &self,
659
+ _req: InitializeRequest,
660
+ ) -> agent_client_protocol::Result<InitializeResponse> {
661
+ log::info!("initialize");
662
656
- let options: Vec<SessionConfigSelectOption> = items
657
- .iter()
658
- .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete)
659
- .map(|item| {
660
- let mut desc_parts = Vec::new();
661
- if item.tool_calling {
662
- desc_parts.push("tool calling".to_string());
663
- }
664
- desc_parts.push(item.description.clone());
665
- if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
666
- desc_parts.push("↓ download on select".to_string());
667
- }
668
- let description = desc_parts.join(" - ");
669
- let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
670
- " [↓ Onde]"
671
- } else {
672
- match item.source_label.as_str() {
673
- "Onde" => " [◉ Onde]",
674
- "HuggingFace" => " [○ HuggingFace]",
675
- _ => "",
676
- }
677
- };
678
- let name = format!("{}{}", item.display_name, source_badge);
679
- SessionConfigSelectOption::new(
680
- SessionConfigValueId::new(item.config.model_id.as_str()),
681
- name,
663
+ Ok(InitializeResponse::new(ProtocolVersion::V1)
664
+ .agent_info(
665
+ Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
666
+ .title("siGit — AI Coding Agent"),
667
)
683
- .description(description)
684
- })
685
- .collect();
668
+ .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new(
669
+ "sigit", "siGit",
670
+ ))])
671
+ .agent_capabilities(
672
+ AgentCapabilities::default()
673
+ .load_session(true)
674
+ .session_capabilities(
675
+ SessionCapabilities::new().fork(SessionForkCapabilities::new()),
676
+ ),
677
+ )
678
+ .meta(initialize_meta()))
679
+ }
680
687
- if options.is_empty() {
688
- return vec![];
681
+ async fn handle_authenticate(
682
+ &self,
683
+ _req: AuthenticateRequest,
684
+ ) -> agent_client_protocol::Result<AuthenticateResponse> {
685
+ log::info!("authenticate");
686
+ Ok(AuthenticateResponse::default())
687
}
688
691
- let current_value = SessionConfigValueId::new(current_model.model_id.as_str());
689
+ async fn handle_load_session(
690
+ &self,
691
+ args: LoadSessionRequest,
692
+ ) -> agent_client_protocol::Result<LoadSessionResponse> {
693
+ log::info!(
694
+ "load_session: id={}, cwd={}, additional_directories={:?}",
695
+ args.session_id,
696
+ args.cwd.display(),
697
+ args.additional_directories
698
+ .iter()
699
+ .map(|p| p.display().to_string())
700
+ .collect::<Vec<_>>()
701
+ );
702
693
- vec![
694
- SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
695
- .category(SessionConfigOptionCategory::Model)
696
- .description("Select the local LLM model for inference"),
697
- ]
698
-}
703
+ if let Ok(mut guard) = self.session_cwd.lock() {
704
+ *guard = Some(args.cwd.clone());
705
+ }
706
700
-/// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None
701
-fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
702
- let items = models::build_model_picker_items();
703
- items
704
- .into_iter()
705
- .find(|item| {
706
- item.config.model_id == model_id
707
- && item.cache_health != setup::ModelCacheHealth::Incomplete
708
- })
709
- .map(|item| (item.config, item.max_tokens, item.tool_calling))
710
-}
707
+ // tool calls use relative paths, so we need to match the editor's cwd
708
+ if args.cwd.is_dir()
709
+ && let Err(err) = std::env::set_current_dir(&args.cwd)
710
+ {
711
+ log::warn!("could not set cwd to {}: {err}", args.cwd.display());
712
+ }
713
712
-#[derive(Debug, Clone)]
713
-enum SlashCommand {
714
- Help,
715
- Clear,
716
- Status,
717
- Models(Option<usize>),
718
- Exit,
719
- Unknown(String),
720
-}
714
+ // no session persistence, so "load" just resets
715
+ self.engine.clear_history().await;
716
722
-fn parse_slash(input: &str) -> Option<SlashCommand> {
723
- let trimmed = input.trim();
724
- if !trimmed.starts_with('/') {
725
- return None;
726
- }
727
- let mut parts = trimmed.splitn(2, char::is_whitespace);
728
- let command = parts.next().unwrap_or("");
729
- let argument = parts.next().map(str::trim);
730
- Some(match command {
731
- "/help" => SlashCommand::Help,
732
- "/clear" => SlashCommand::Clear,
733
- "/status" => SlashCommand::Status,
734
- "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
735
- "/exit" | "/quit" | "/q" => SlashCommand::Exit,
736
- other => SlashCommand::Unknown(other.to_string()),
737
- })
738
-}
717
+ self.engine
718
+ .push_history(onde::inference::ChatMessage::system(format!(
719
+ "The user's project working directory is {}. \
720
+ Always use absolute paths under this directory for all file \
721
+ and directory operations. This is the root of the project \
722
+ the user has open in their editor.",
723
+ args.cwd.display()
724
+ )))
725
+ .await;
726
740
-fn format_models_list(current_model: &GgufModelConfig) -> String {
741
- let items = models::build_model_picker_items();
742
- if items.is_empty() {
743
- return "No local models found. siGit will use the platform default model.".to_string();
744
- }
727
+ let config_options = {
728
+ let guard = self.current_model.lock().unwrap();
729
+ build_model_config_options(&guard)
730
+ };
731
746
- let mut lines = vec!["Available models:".to_string()];
747
- let mut last_source: Option<&str> = None;
732
+ Ok(LoadSessionResponse::new().config_options(config_options))
733
+ }
734
749
- for (index, item) in items.iter().enumerate() {
750
- let source_key = match item.source_label.as_str() {
751
- "Onde" => "Onde",
752
- "HuggingFace" => "HuggingFace",
753
- _ => "Fallback",
754
- };
735
+ async fn handle_fork_session(
736
+ &self,
737
+ args: ForkSessionRequest,
738
+ ) -> agent_client_protocol::Result<ForkSessionResponse> {
739
+ let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
740
+ log::info!(
741
+ "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
742
+ args.session_id,
743
+ args.cwd.display(),
744
+ args.additional_directories
745
+ .iter()
746
+ .map(|p| p.display().to_string())
747
+ .collect::<Vec<_>>()
748
+ );
749
756
- if last_source != Some(source_key) {
757
- if last_source.is_some() {
758
- lines.push(String::new());
759
- }
760
- let section = match source_key {
761
- "Onde" => "Onde Inference",
762
- "HuggingFace" => "Hugging Face cache",
763
- _ => "Fallback",
764
- };
765
- lines.push(section.to_string());
766
- last_source = Some(source_key);
750
+ if let Ok(mut guard) = self.session_cwd.lock() {
751
+ *guard = Some(args.cwd.clone());
752
+ }
753
+ if args.cwd.is_dir()
754
+ && let Err(err) = std::env::set_current_dir(&args.cwd)
755
+ {
756
+ log::warn!("could not set cwd to {}: {err}", args.cwd.display());
757
}
758
769
- let number = index + 1;
770
- let current_badge = if item.config.model_id == current_model.model_id {
771
- " <- current"
772
- } else {
773
- ""
774
- };
775
- let tool_badge = if item.tool_calling {
776
- " tool calling"
777
- } else {
778
- ""
779
- };
780
- let health_badge = match item.cache_health {
781
- setup::ModelCacheHealth::Complete => "",
782
- setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
783
- setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
784
- };
785
- let source = match source_key {
786
- "Onde" => " [Onde]",
787
- "HuggingFace" => " [HuggingFace]",
788
- _ => " [default]",
759
+ // no persistence, so fork == fresh session
760
+ self.engine.clear_history().await;
761
+
762
+ self.engine
763
+ .push_history(onde::inference::ChatMessage::system(format!(
764
+ "The user's project working directory is {}. \
765
+ Always use absolute paths under this directory for all file \
766
+ and directory operations. This is the root of the project \
767
+ the user has open in their editor.",
768
+ args.cwd.display()
769
+ )))
770
+ .await;
771
+
772
+ let config_options = {
773
+ let guard = self.current_model.lock().unwrap();
774
+ build_model_config_options(&guard)
775
};
776
791
- lines.push(format!(
792
- "{number}. {} {}{}{}{}{}",
793
- item.display_name, item.description, tool_badge, health_badge, current_badge, source,
794
- ));
777
+ Ok(ForkSessionResponse::new(new_id).config_options(config_options))
778
}
779
797
- lines.push(String::new());
798
- lines.push("Use /models N to switch models.".to_string());
799
- lines.join("\n")
800
-}
801
-
802
-async fn exec_slash_acp(
803
- agent: &SiGitAgent,
804
- session_id: SessionId,
805
- command: SlashCommand,
806
-) -> agent_client_protocol::Result<PromptResponse> {
807
- match command {
808
- SlashCommand::Help => {
809
- agent
810
- .send_assistant_message(
811
- session_id,
812
- "/help - show this message\n\
813
- /models - list available models\n\
814
- /models N - switch to model N\n\
815
- /clear - wipe conversation history\n\
816
- /status - show engine status\n\
817
- /exit - end this turn",
818
- )
819
- .await;
820
- }
821
- SlashCommand::Clear => {
822
- let cleared = agent.engine.clear_history().await;
823
- agent
824
- .send_assistant_message(
825
- session_id,
826
- format!("Cleared {cleared} turn(s). History is empty."),
827
- )
828
- .await;
829
- }
830
- SlashCommand::Status => {
831
- let info = agent.engine.info().await;
832
- let model = info.model_name.as_deref().unwrap_or("(none)");
833
- let memory = info.approx_memory.as_deref().unwrap_or("unknown");
834
- agent
835
- .send_assistant_message(
836
- session_id,
837
- format!(
838
- "status: {:?} model: {} memory: {} history: {} turns",
839
- info.status, model, memory, info.history_length,
840
- ),
841
- )
842
- .await;
843
- }
844
- SlashCommand::Models(None) => {
845
- let current_model = agent.current_model.lock().unwrap().clone();
846
- agent
847
- .send_assistant_message(session_id, format_models_list(¤t_model))
848
- .await;
849
- }
850
- SlashCommand::Models(Some(number)) => {
851
- let items = models::build_model_picker_items();
852
- let index = number.saturating_sub(1);
853
- match items.get(index).cloned() {
854
- None => {
855
- agent
856
- .send_assistant_message(
857
- session_id,
858
- format!("error: no model #{number} - type /models to see the list."),
859
- )
860
- .await;
861
- }
862
- Some(model) => {
863
- if model.cache_health == setup::ModelCacheHealth::Incomplete {
864
- agent
865
- .send_assistant_message(
866
- session_id,
867
- format!(
868
- "error: {} has an incomplete local cache and cannot be selected yet.",
869
- model.display_name
870
- ),
871
- )
872
- .await;
873
- } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded {
874
- agent
875
- .send_assistant_message(
876
- session_id.clone(),
877
- format!(
878
- "Downloading and loading {} ({})… this may take a few minutes.",
879
- model.display_name, model.description
880
- ),
881
- )
882
- .await;
883
-
884
- match agent.switch_model_by_id(&model.config.model_id).await {
885
- Ok(new_config) => {
886
- agent.engine.clear_history().await;
887
- agent
888
- .send_assistant_message(
889
- session_id,
890
- format!(
891
- "✓ Downloaded and switched to {}",
892
- new_config.display_name
893
- ),
894
- )
895
- .await;
896
- }
897
- Err(err) => {
898
- agent
899
- .send_assistant_message(
900
- session_id,
901
- format!("error downloading model: {}", err.message),
902
- )
903
- .await;
904
- }
905
- }
906
- } else {
907
- agent
908
- .send_assistant_message(
909
- session_id.clone(),
910
- format!("Loading {}...", model.display_name),
911
- )
912
- .await;
913
-
914
- let switched = agent.switch_model_by_id(&model.config.model_id).await?;
915
- agent.engine.clear_history().await;
916
-
917
- agent
918
- .send_assistant_message(
919
- session_id,
920
- format!("Switched to {}.", switched.display_name),
921
- )
922
- .await;
923
- }
924
- }
925
- }
926
- }
927
- SlashCommand::Exit => {
928
- agent
929
- .send_assistant_message(
930
- session_id,
931
- "Use the panel controls to close or switch threads.",
932
- )
933
- .await;
934
- }
935
- SlashCommand::Unknown(command) => {
936
- agent
937
- .send_assistant_message(session_id, format!("unknown command: {command}"))
938
- .await;
939
- }
940
- }
941
-
942
- Ok(PromptResponse::new(StopReason::EndTurn))
943
-}
944
-
945
-#[async_trait::async_trait(?Send)]
946
-impl Agent for SiGitAgent {
947
- async fn initialize(
948
- &self,
949
- _args: InitializeRequest,
950
- ) -> agent_client_protocol::Result<InitializeResponse> {
951
- log::info!("initialize");
952
-
953
- Ok(InitializeResponse::new(ProtocolVersion::V1)
954
- .agent_info(
955
- Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
956
- .title("siGit — AI Coding Agent"),
957
- )
958
- .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new(
959
- "sigit", "siGit",
960
- ))])
961
- .agent_capabilities(
962
- AgentCapabilities::default()
963
- .load_session(true)
964
- .session_capabilities(
965
- SessionCapabilities::new().fork(SessionForkCapabilities::new()),
966
- ),
967
- )
968
- .meta(initialize_meta()))
969
- }
970
-
971
- async fn authenticate(
972
- &self,
973
- _args: AuthenticateRequest,
974
- ) -> agent_client_protocol::Result<AuthenticateResponse> {
975
- log::info!("authenticate");
976
- Ok(AuthenticateResponse::default())
977
- }
978
-
979
- async fn load_session(
980
- &self,
981
- args: LoadSessionRequest,
982
- ) -> agent_client_protocol::Result<LoadSessionResponse> {
983
- log::info!(
984
- "load_session: id={}, cwd={}, additional_directories={:?}",
985
- args.session_id,
986
- args.cwd.display(),
987
- args.additional_directories
988
- .iter()
989
- .map(|p| p.display().to_string())
990
- .collect::<Vec<_>>()
991
- );
992
-
993
- if let Ok(mut guard) = self.session_cwd.lock() {
994
- *guard = Some(args.cwd.clone());
995
- }
996
-
997
- // tool calls use relative paths, so we need to match the editor's cwd
998
- if args.cwd.is_dir()
999
- && let Err(err) = std::env::set_current_dir(&args.cwd)
1000
- {
1001
- log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1002
- }
1003
-
1004
- // no session persistence, so "load" just resets
1005
- self.engine.clear_history().await;
1006
-
1007
- self.engine
1008
- .push_history(onde::inference::ChatMessage::system(format!(
1009
- "The user's project working directory is {}. \
1010
- Always use absolute paths under this directory for all file \
1011
- and directory operations. This is the root of the project \
1012
- the user has open in their editor.",
1013
- args.cwd.display()
1014
- )))
1015
- .await;
1016
-
1017
- let config_options = {
1018
- let guard = self.current_model.lock().unwrap();
1019
- build_model_config_options(&guard)
1020
- };
1021
-
1022
- Ok(LoadSessionResponse::new().config_options(config_options))
1023
- }
1024
-
1025
- async fn fork_session(
1026
- &self,
1027
- args: ForkSessionRequest,
1028
- ) -> agent_client_protocol::Result<ForkSessionResponse> {
1029
- let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1030
- log::info!(
1031
- "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
1032
- args.session_id,
1033
- args.cwd.display(),
1034
- args.additional_directories
1035
- .iter()
1036
- .map(|p| p.display().to_string())
1037
- .collect::<Vec<_>>()
1038
- );
1039
-
1040
- if let Ok(mut guard) = self.session_cwd.lock() {
1041
- *guard = Some(args.cwd.clone());
1042
- }
1043
- if args.cwd.is_dir()
1044
- && let Err(err) = std::env::set_current_dir(&args.cwd)
1045
- {
1046
- log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1047
- }
1048
-
1049
- // no persistence, so fork == fresh session
1050
- self.engine.clear_history().await;
1051
-
1052
- self.engine
1053
- .push_history(onde::inference::ChatMessage::system(format!(
1054
- "The user's project working directory is {}. \
1055
- Always use absolute paths under this directory for all file \
1056
- and directory operations. This is the root of the project \
1057
- the user has open in their editor.",
1058
- args.cwd.display()
1059
- )))
1060
- .await;
1061
-
1062
- let config_options = {
1063
- let guard = self.current_model.lock().unwrap();
1064
- build_model_config_options(&guard)
1065
- };
1066
-
1067
- Ok(ForkSessionResponse::new(new_id).config_options(config_options))
1068
- }
1069
-
1070
- async fn new_session(
1071
- &self,
1072
- args: NewSessionRequest,
1073
- ) -> agent_client_protocol::Result<NewSessionResponse> {
1074
- let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1075
- log::info!(
1076
- "new_session: id={session_id}, cwd={}, additional_directories={:?}",
1077
- args.cwd.display(),
1078
- args.additional_directories
1079
- .iter()
1080
- .map(|p| p.display().to_string())
1081
- .collect::<Vec<_>>()
1082
- );
780
+ async fn handle_new_session(
781
+ &self,
782
+ args: NewSessionRequest,
783
+ ) -> agent_client_protocol::Result<NewSessionResponse> {
784
+ let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
785
+ log::info!(
786
+ "new_session: id={session_id}, cwd={}, additional_directories={:?}",
787
+ args.cwd.display(),
788
+ args.additional_directories
789
+ .iter()
790
+ .map(|p| p.display().to_string())
791
+ .collect::<Vec<_>>()
792
+ );
793
794
if let Ok(mut guard) = self.session_cwd.lock() {
795
*guard = Some(args.cwd.clone());
@@ -1110,7 +820,11 @@ impl Agent for SiGitAgent {
820
Ok(NewSessionResponse::new(session_id).config_options(config_options))
821
}
822
1113
- async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> {
823
+ async fn handle_prompt(
824
+ &self,
825
+ cx: &ConnectionTo<Client>,
826
+ args: PromptRequest,
827
+ ) -> agent_client_protocol::Result<PromptResponse> {
828
let session_id = args.session_id.clone();
829
830
// log every block so we can debug @ references and file context
@@ -1131,9 +845,9 @@ impl Agent for SiGitAgent {
845
session_id,
846
i,
847
match &embedded.resource {
1134
- agent_client_protocol::EmbeddedResourceResource::TextResourceContents(t) =>
848
+ EmbeddedResourceResource::TextResourceContents(t) =>
849
format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()),
1136
- agent_client_protocol::EmbeddedResourceResource::BlobResourceContents(b) =>
850
+ EmbeddedResourceResource::BlobResourceContents(b) =>
851
format!("BlobResource(uri={})", b.uri),
852
_ => "Unknown".to_string(),
853
}
@@ -1171,17 +885,13 @@ impl Agent for SiGitAgent {
885
ContentBlock::Resource(embedded) => {
886
// editor inlined the file content already
887
match &embedded.resource {
1174
- agent_client_protocol::EmbeddedResourceResource::TextResourceContents(
1175
- text_resource,
1176
- ) => {
888
+ EmbeddedResourceResource::TextResourceContents(text_resource) => {
889
parts.push(format!(
890
"\n--- {} ---\n{}\n--- end {} ---",
891
text_resource.uri, text_resource.text, text_resource.uri
892
));
893
}
1182
- agent_client_protocol::EmbeddedResourceResource::BlobResourceContents(
1183
- blob,
1184
- ) => {
894
+ EmbeddedResourceResource::BlobResourceContents(blob) => {
895
parts.push(format!("[binary resource: {}]", blob.uri));
896
}
897
_ => {
@@ -1255,7 +965,7 @@ impl Agent for SiGitAgent {
965
}
966
967
if let Some(command) = parse_slash(&user_text) {
1258
- return exec_slash_acp(self, session_id, command).await;
968
+ return exec_slash_acp(self, cx, session_id, command).await;
969
}
970
971
log::info!(
@@ -1267,7 +977,7 @@ impl Agent for SiGitAgent {
977
// load the default ACP model lazily so initialize/session/new stay clean
978
// for registry validation and editor startup.
979
self.start_startup_model_load_if_needed();
1270
- self.await_model_ready(&session_id).await?;
980
+ self.await_model_ready(cx, &session_id).await?;
981
982
// ── tool-calling loop ────────────────────────────────────────────
983
// send message → execute any tool calls → feed results back
@@ -1352,26 +1062,25 @@ impl Agent for SiGitAgent {
1062
};
1063
1064
if !final_text.is_empty() {
1355
- let notification = SessionNotification::new(
1356
- session_id.clone(),
1357
- SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(final_text))),
1358
- );
1359
- if self.notification_tx.send(notification).await.is_err() {
1360
- log::warn!("notification channel closed");
1361
- }
1065
+ self.send_assistant_message(cx, session_id.clone(), final_text)
1066
+ .ok();
1067
}
1068
1069
log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
1070
Ok(PromptResponse::new(StopReason::EndTurn))
1071
}
1072
1368
- async fn cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
1073
+ async fn handle_cancel(
1074
+ &self,
1075
+ args: CancelNotification,
1076
+ ) -> agent_client_protocol::Result<()> {
1077
log::info!("cancel requested for session {}", args.session_id);
1078
Ok(())
1079
}
1080
1373
- async fn set_session_config_option(
1081
+ async fn handle_set_session_config_option(
1082
&self,
1083
+ cx: &ConnectionTo<Client>,
1084
args: SetSessionConfigOptionRequest,
1085
) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
1086
log::info!(
@@ -1445,6 +1154,7 @@ impl Agent for SiGitAgent {
1154
};
1155
1156
self.send_tool_call_update(
1157
+ cx,
1158
args.session_id.clone(),
1159
SessionUpdate::ToolCall(
1160
ToolCall::new(
@@ -1461,16 +1171,16 @@ impl Agent for SiGitAgent {
1171
]),
1172
),
1173
)
1464
- .await;
1174
+ .ok();
1175
1176
// poll download progress and update the spinner in Zed
1467
- let poller_tx = self.notification_tx.clone();
1177
+ let cx_for_poller = cx.clone();
1178
let poller_session = args.session_id.clone();
1179
let poller_model_id = model_id_owned.clone();
1180
let poller_stop = Arc::clone(&stop_flag);
1181
let poller_tool_call_id = tool_call_id.clone();
1182
1473
- tokio::task::spawn_local(async move {
1183
+ cx.spawn(async move {
1184
const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1185
let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
1186
let mut tick: usize = 0;
@@ -1527,11 +1237,13 @@ impl Agent for SiGitAgent {
1237
.content(vec![msg.into()]),
1238
)),
1239
);
1530
- if poller_tx.send(notification).await.is_err() {
1240
+ if cx_for_poller.send_notification(notification).is_err() {
1241
break;
1242
}
1243
}
1534
- });
1244
+ Ok(())
1245
+ })
1246
+ .ok();
1247
}
1248
1249
// cached models still take 10-30s to load weights; show a spinner
@@ -1543,6 +1255,7 @@ impl Agent for SiGitAgent {
1255
.unwrap_or_else(|| model_id.to_string());
1256
1257
self.send_tool_call_update(
1258
+ cx,
1259
args.session_id.clone(),
1260
SessionUpdate::ToolCall(
1261
ToolCall::new(
@@ -1554,108 +1267,435 @@ impl Agent for SiGitAgent {
1267
.content(vec![format!("Loading {cached_display_name}…").into()]),
1268
),
1269
)
1557
- .await;
1270
+ .ok();
1271
1272
// tick every 5s so the user knows we haven't frozen
1560
- let spinner_tx = self.notification_tx.clone();
1273
+ let cx_for_spinner = cx.clone();
1274
let spinner_session = args.session_id.clone();
1275
let spinner_name = cached_display_name.clone();
1276
let spinner_stop = Arc::clone(&stop_flag);
1277
let spinner_tool_call_id = tool_call_id.clone();
1278
let load_start = std::time::Instant::now();
1279
1567
- tokio::task::spawn_local(async move {
1568
- const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1569
- let mut tick: usize = 0;
1570
- let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
1571
- interval.tick().await; // consume the immediate first tick
1280
+ cx.spawn(async move {
1281
+ const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1282
+ let mut tick: usize = 0;
1283
+ let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
1284
+ interval.tick().await; // consume the immediate first tick
1285
+
1286
+ while !spinner_stop.load(Ordering::Relaxed) {
1287
+ interval.tick().await;
1288
+
1289
+ if spinner_stop.load(Ordering::Relaxed) {
1290
+ break;
1291
+ }
1292
+
1293
+ let elapsed = load_start.elapsed();
1294
+ let elapsed_str = if elapsed.as_secs() >= 60 {
1295
+ format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
1296
+ } else {
1297
+ format!("{}s", elapsed.as_secs())
1298
+ };
1299
+ let frame = SPINNER[tick % SPINNER.len()];
1300
+ tick += 1;
1301
+
1302
+ let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
1303
+ let notification = SessionNotification::new(
1304
+ spinner_session.clone(),
1305
+ SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1306
+ spinner_tool_call_id.clone(),
1307
+ ToolCallUpdateFields::new()
1308
+ .status(ToolCallStatus::InProgress)
1309
+ .content(vec![msg.into()]),
1310
+ )),
1311
+ );
1312
+ if cx_for_spinner.send_notification(notification).is_err() {
1313
+ break;
1314
+ }
1315
+ }
1316
+ Ok(())
1317
+ })
1318
+ .ok();
1319
+ }
1320
+
1321
+ let switch_result = self.switch_model_by_id(model_id).await;
1322
+
1323
+ stop_flag.store(true, Ordering::Relaxed);
1324
+
1325
+ match switch_result {
1326
+ Ok(new_config) => {
1327
+ let completion_title = if needs_download {
1328
+ format!("✓ {} downloaded and loaded", new_config.display_name)
1329
+ } else {
1330
+ format!("✓ Switched to {}", new_config.display_name)
1331
+ };
1332
+ let completion_body = if needs_download {
1333
+ format!("✓ {} downloaded and loaded.", new_config.display_name)
1334
+ } else {
1335
+ format!("✓ Switched to {}.", new_config.display_name)
1336
+ };
1337
+
1338
+ self.send_tool_call_update(
1339
+ cx,
1340
+ args.session_id.clone(),
1341
+ SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1342
+ tool_call_id,
1343
+ ToolCallUpdateFields::new()
1344
+ .title(completion_title)
1345
+ .status(ToolCallStatus::Completed)
1346
+ .content(vec![completion_body.into()]),
1347
+ )),
1348
+ )
1349
+ .ok();
1350
+
1351
+ let config_options = {
1352
+ let guard = self.current_model.lock().unwrap();
1353
+ build_model_config_options(&guard)
1354
+ };
1355
+
1356
+ log::info!("model switch complete");
1357
+ Ok(SetSessionConfigOptionResponse::new(config_options))
1358
+ }
1359
+ Err(err) => {
1360
+ self.send_tool_call_update(
1361
+ cx,
1362
+ args.session_id.clone(),
1363
+ SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1364
+ tool_call_id,
1365
+ ToolCallUpdateFields::new()
1366
+ .title("Model switch failed".to_string())
1367
+ .status(ToolCallStatus::Failed)
1368
+ .content(vec![format!("error loading model: {}", err.message).into()]),
1369
+ )),
1370
+ )
1371
+ .ok();
1372
+
1373
+ Err(err)
1374
+ }
1375
+ }
1376
+ }
1377
+}
1378
+
1379
+// ── Config option helpers ─────────────────────────────────────────────────────
1380
+
1381
+/// config option ID for the model picker in Zed's agent panel
1382
+const MODEL_CONFIG_ID: &str = "sigit-model";
1383
+
1384
+fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
1385
+ let items = models::build_model_picker_items();
1386
+
1387
+ let options: Vec<SessionConfigSelectOption> = items
1388
+ .iter()
1389
+ .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete)
1390
+ .map(|item| {
1391
+ let mut desc_parts = Vec::new();
1392
+ if item.tool_calling {
1393
+ desc_parts.push("tool calling".to_string());
1394
+ }
1395
+ desc_parts.push(item.description.clone());
1396
+ if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
1397
+ desc_parts.push("↓ download on select".to_string());
1398
+ }
1399
+ let description = desc_parts.join(" - ");
1400
+ let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
1401
+ " [↓ Onde]"
1402
+ } else {
1403
+ match item.source_label.as_str() {
1404
+ "Onde" => " [◉ Onde]",
1405
+ "HuggingFace" => " [○ HuggingFace]",
1406
+ _ => "",
1407
+ }
1408
+ };
1409
+ let name = format!("{}{}", item.display_name, source_badge);
1410
+ SessionConfigSelectOption::new(
1411
+ SessionConfigValueId::new(item.config.model_id.as_str()),
1412
+ name,
1413
+ )
1414
+ .description(description)
1415
+ })
1416
+ .collect();
1417
+
1418
+ if options.is_empty() {
1419
+ return vec![];
1420
+ }
1421
+
1422
+ let current_value = SessionConfigValueId::new(current_model.model_id.as_str());
1423
+
1424
+ vec![
1425
+ SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
1426
+ .category(SessionConfigOptionCategory::Model)
1427
+ .description("Select the local LLM model for inference"),
1428
+ ]
1429
+}
1430
+
1431
+/// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None
1432
+fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
1433
+ let items = models::build_model_picker_items();
1434
+ items
1435
+ .into_iter()
1436
+ .find(|item| {
1437
+ item.config.model_id == model_id
1438
+ && item.cache_health != setup::ModelCacheHealth::Incomplete
1439
+ })
1440
+ .map(|item| (item.config, item.max_tokens, item.tool_calling))
1441
+}
1442
+
1443
+// ── Slash commands ────────────────────────────────────────────────────────────
1444
+
1445
+#[derive(Debug, Clone)]
1446
+enum SlashCommand {
1447
+ Help,
1448
+ Clear,
1449
+ Status,
1450
+ Models(Option<usize>),
1451
+ Exit,
1452
+ Unknown(String),
1453
+}
1454
+
1455
+fn parse_slash(input: &str) -> Option<SlashCommand> {
1456
+ let trimmed = input.trim();
1457
+ if !trimmed.starts_with('/') {
1458
+ return None;
1459
+ }
1460
+ let mut parts = trimmed.splitn(2, char::is_whitespace);
1461
+ let command = parts.next().unwrap_or("");
1462
+ let argument = parts.next().map(str::trim);
1463
+ Some(match command {
1464
+ "/help" => SlashCommand::Help,
1465
+ "/clear" => SlashCommand::Clear,
1466
+ "/status" => SlashCommand::Status,
1467
+ "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
1468
+ "/exit" | "/quit" | "/q" => SlashCommand::Exit,
1469
+ other => SlashCommand::Unknown(other.to_string()),
1470
+ })
1471
+}
1472
+
1473
+fn format_models_list(current_model: &GgufModelConfig) -> String {
1474
+ let items = models::build_model_picker_items();
1475
+ if items.is_empty() {
1476
+ return "No local models found. siGit will use the platform default model.".to_string();
1477
+ }
1478
+
1479
+ let mut lines = vec!["Available models:".to_string()];
1480
+ let mut last_source: Option<&str> = None;
1481
+
1482
+ for (index, item) in items.iter().enumerate() {
1483
+ let source_key = match item.source_label.as_str() {
1484
+ "Onde" => "Onde",
1485
+ "HuggingFace" => "HuggingFace",
1486
+ _ => "Fallback",
1487
+ };
1488
+
1489
+ if last_source != Some(source_key) {
1490
+ if last_source.is_some() {
1491
+ lines.push(String::new());
1492
+ }
1493
+ let section = match source_key {
1494
+ "Onde" => "Onde Inference",
1495
+ "HuggingFace" => "Hugging Face cache",
1496
+ _ => "Fallback",
1497
+ };
1498
+ lines.push(section.to_string());
1499
+ last_source = Some(source_key);
1500
+ }
1501
+
1502
+ let number = index + 1;
1503
+ let current_badge = if item.config.model_id == current_model.model_id {
1504
+ " <- current"
1505
+ } else {
1506
+ ""
1507
+ };
1508
+ let tool_badge = if item.tool_calling {
1509
+ " tool calling"
1510
+ } else {
1511
+ ""
1512
+ };
1513
+ let health_badge = match item.cache_health {
1514
+ setup::ModelCacheHealth::Complete => "",
1515
+ setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
1516
+ setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
1517
+ };
1518
+ let source = match source_key {
1519
+ "Onde" => " [Onde]",
1520
+ "HuggingFace" => " [HuggingFace]",
1521
+ _ => " [default]",
1522
+ };
1523
1573
- while !spinner_stop.load(Ordering::Relaxed) {
1574
- interval.tick().await;
1524
+ lines.push(format!(
1525
+ "{number}. {} {}{}{}{}{}",
1526
+ item.display_name, item.description, tool_badge, health_badge, current_badge, source,
1527
+ ));
1528
+ }
1529
1576
- if spinner_stop.load(Ordering::Relaxed) {
1577
- break;
1578
- }
1530
+ lines.push(String::new());
1531
+ lines.push("Use /models N to switch models.".to_string());
1532
+ lines.join("\n")
1533
+}
1534
1580
- let elapsed = load_start.elapsed();
1581
- let elapsed_str = if elapsed.as_secs() >= 60 {
1582
- format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
1535
+async fn exec_slash_acp(
1536
+ agent: &SiGitAgent,
1537
+ cx: &ConnectionTo<Client>,
1538
+ session_id: SessionId,
1539
+ command: SlashCommand,
1540
+) -> agent_client_protocol::Result<PromptResponse> {
1541
+ match command {
1542
+ SlashCommand::Help => {
1543
+ agent
1544
+ .send_assistant_message(
1545
+ cx,
1546
+ session_id,
1547
+ "/help - show this message\n\
1548
+ /models - list available models\n\
1549
+ /models N - switch to model N\n\
1550
+ /clear - wipe conversation history\n\
1551
+ /status - show engine status\n\
1552
+ /exit - end this turn",
1553
+ )
1554
+ .ok();
1555
+ }
1556
+ SlashCommand::Clear => {
1557
+ let cleared = agent.engine.clear_history().await;
1558
+ agent
1559
+ .send_assistant_message(
1560
+ cx,
1561
+ session_id,
1562
+ format!("Cleared {cleared} turn(s). History is empty."),
1563
+ )
1564
+ .ok();
1565
+ }
1566
+ SlashCommand::Status => {
1567
+ let info = agent.engine.info().await;
1568
+ let model = info.model_name.as_deref().unwrap_or("(none)");
1569
+ let memory = info.approx_memory.as_deref().unwrap_or("unknown");
1570
+ agent
1571
+ .send_assistant_message(
1572
+ cx,
1573
+ session_id,
1574
+ format!(
1575
+ "status: {:?} model: {} memory: {} history: {} turns",
1576
+ info.status, model, memory, info.history_length,
1577
+ ),
1578
+ )
1579
+ .ok();
1580
+ }
1581
+ SlashCommand::Models(None) => {
1582
+ let current_model = agent.current_model.lock().unwrap().clone();
1583
+ agent
1584
+ .send_assistant_message(cx, session_id, format_models_list(¤t_model))
1585
+ .ok();
1586
+ }
1587
+ SlashCommand::Models(Some(number)) => {
1588
+ let items = models::build_model_picker_items();
1589
+ let index = number.saturating_sub(1);
1590
+ match items.get(index).cloned() {
1591
+ None => {
1592
+ agent
1593
+ .send_assistant_message(
1594
+ cx,
1595
+ session_id,
1596
+ format!("error: no model #{number} - type /models to see the list."),
1597
+ )
1598
+ .ok();
1599
+ }
1600
+ Some(model) => {
1601
+ if model.cache_health == setup::ModelCacheHealth::Incomplete {
1602
+ agent
1603
+ .send_assistant_message(
1604
+ cx,
1605
+ session_id,
1606
+ format!(
1607
+ "error: {} has an incomplete local cache and cannot be selected yet.",
1608
+ model.display_name
1609
+ ),
1610
+ )
1611
+ .ok();
1612
+ } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded {
1613
+ agent
1614
+ .send_assistant_message(
1615
+ cx,
1616
+ session_id.clone(),
1617
+ format!(
1618
+ "Downloading and loading {} ({})… this may take a few minutes.",
1619
+ model.display_name, model.description
1620
+ ),
1621
+ )
1622
+ .ok();
1623
+
1624
+ match agent.switch_model_by_id(&model.config.model_id).await {
1625
+ Ok(new_config) => {
1626
+ agent.engine.clear_history().await;
1627
+ agent
1628
+ .send_assistant_message(
1629
+ cx,
1630
+ session_id,
1631
+ format!(
1632
+ "✓ Downloaded and switched to {}",
1633
+ new_config.display_name
1634
+ ),
1635
+ )
1636
+ .ok();
1637
+ }
1638
+ Err(err) => {
1639
+ agent
1640
+ .send_assistant_message(
1641
+ cx,
1642
+ session_id,
1643
+ format!("error downloading model: {}", err.message),
1644
+ )
1645
+ .ok();
1646
+ }
1647
+ }
1648
} else {
1584
- format!("{}s", elapsed.as_secs())
1585
- };
1586
- let frame = SPINNER[tick % SPINNER.len()];
1587
- tick += 1;
1649
+ agent
1650
+ .send_assistant_message(
1651
+ cx,
1652
+ session_id.clone(),
1653
+ format!("Loading {}...", model.display_name),
1654
+ )
1655
+ .ok();
1656
1589
- let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
1590
- let notification = SessionNotification::new(
1591
- spinner_session.clone(),
1592
- SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1593
- spinner_tool_call_id.clone(),
1594
- ToolCallUpdateFields::new()
1595
- .status(ToolCallStatus::InProgress)
1596
- .content(vec![msg.into()]),
1597
- )),
1598
- );
1599
- if spinner_tx.send(notification).await.is_err() {
1600
- break;
1657
+ let switched = agent.switch_model_by_id(&model.config.model_id).await?;
1658
+ agent.engine.clear_history().await;
1659
+
1660
+ agent
1661
+ .send_assistant_message(
1662
+ cx,
1663
+ session_id,
1664
+ format!("Switched to {}.", switched.display_name),
1665
+ )
1666
+ .ok();
1667
}
1668
}
1603
- });
1669
+ }
1670
}
1605
-
1606
- let switch_result = self.switch_model_by_id(model_id).await;
1607
-
1608
- stop_flag.store(true, Ordering::Relaxed);
1609
-
1610
- match switch_result {
1611
- Ok(new_config) => {
1612
- let completion_title = if needs_download {
1613
- format!("✓ {} downloaded and loaded", new_config.display_name)
1614
- } else {
1615
- format!("✓ Switched to {}", new_config.display_name)
1616
- };
1617
- let completion_body = if needs_download {
1618
- format!("✓ {} downloaded and loaded.", new_config.display_name)
1619
- } else {
1620
- format!("✓ Switched to {}.", new_config.display_name)
1621
- };
1622
-
1623
- self.send_tool_call_update(
1624
- args.session_id.clone(),
1625
- SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1626
- tool_call_id,
1627
- ToolCallUpdateFields::new()
1628
- .title(completion_title)
1629
- .status(ToolCallStatus::Completed)
1630
- .content(vec![completion_body.into()]),
1631
- )),
1671
+ SlashCommand::Exit => {
1672
+ agent
1673
+ .send_assistant_message(
1674
+ cx,
1675
+ session_id,
1676
+ "Use the panel controls to close or switch threads.",
1677
)
1633
- .await;
1678
+ .ok();
1679
+ }
1680
+ SlashCommand::Unknown(command) => {
1681
+ agent
1682
+ .send_assistant_message(cx, session_id, format!("unknown command: {command}"))
1683
+ .ok();
1684
+ }
1685
+ }
1686
1635
- let config_options = {
1636
- let guard = self.current_model.lock().unwrap();
1637
- build_model_config_options(&guard)
1638
- };
1687
+ Ok(PromptResponse::new(StopReason::EndTurn))
1688
+}
1689
1640
- log::info!("model switch complete");
1641
- Ok(SetSessionConfigOptionResponse::new(config_options))
1642
- }
1643
- Err(err) => {
1644
- self.send_tool_call_update(
1645
- args.session_id.clone(),
1646
- SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1647
- tool_call_id,
1648
- ToolCallUpdateFields::new()
1649
- .title("Model switch failed".to_string())
1650
- .status(ToolCallStatus::Failed)
1651
- .content(vec![format!("error loading model: {}", err.message).into()]),
1652
- )),
1653
- )
1654
- .await;
1690
+// ── Request dispatch helper ───────────────────────────────────────────────────
1691
1656
- Err(err)
1657
- }
1658
- }
1692
+fn handle_response<T: agent_client_protocol::JsonRpcResponse>(
1693
+ responder: Responder<T>,
1694
+ result: agent_client_protocol::Result<T>,
1695
+) -> agent_client_protocol::Result<()> {
1696
+ match result {
1697
+ Ok(resp) => responder.respond(resp),
1698
+ Err(err) => responder.respond_with_error(err),
1699
}
1700
}
1701
@@ -1887,48 +1927,99 @@ async fn run_acp_server() -> anyhow::Result<()> {
1927
let model_load_error: Arc<std::sync::Mutex<Option<String>>> =
1928
Arc::new(std::sync::Mutex::new(None));
1929
1890
- let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
1891
- let agent = SiGitAgent::new(
1930
+ let state = Arc::new(SiGitAgent::new(
1931
engine,
1893
- notification_tx,
1932
config,
1933
model_ready,
1934
startup_model_load_started,
1935
model_load_error,
1936
needs_download,
1899
- );
1937
+ ));
1938
1901
- // AgentSideConnection needs futures-io
1939
let stdin = tokio::io::stdin().compat();
1940
let stdout = tokio::io::stdout().compat_write();
1904
-
1905
- // ACP futures are !Send
1906
- let local = tokio::task::LocalSet::new();
1907
-
1908
- local
1909
- .run_until(async move {
1910
- let (conn, io_task) = AgentSideConnection::new(
1911
- agent,
1912
- stdout,
1913
- stdin,
1914
- |fut: LocalBoxFuture<'static, ()>| {
1915
- tokio::task::spawn_local(fut);
1916
- },
1917
- );
1918
-
1919
- tokio::task::spawn_local(async move {
1920
- while let Some(notification) = notification_rx.recv().await {
1921
- if let Err(err) = conn.session_notification(notification).await {
1922
- log::warn!("session_notification failed: {err}");
1923
- }
1941
+ let transport = ByteStreams::new(stdout, stdin);
1942
+
1943
+ Agent
1944
+ .builder()
1945
+ .on_receive_request(
1946
+ {
1947
+ let state = Arc::clone(&state);
1948
+ async move |req: InitializeRequest, responder, _cx: ConnectionTo<Client>| {
1949
+ handle_response(responder, state.handle_initialize(req).await)
1950
}
1925
- });
1926
-
1927
- if let Err(err) = io_task.await {
1928
- log::error!("ACP IO error: {err}");
1929
- }
1930
- })
1931
- .await;
1951
+ },
1952
+ agent_client_protocol::on_receive_request!(),
1953
+ )
1954
+ .on_receive_request(
1955
+ {
1956
+ let state = Arc::clone(&state);
1957
+ async move |req: AuthenticateRequest, responder, _cx: ConnectionTo<Client>| {
1958
+ handle_response(responder, state.handle_authenticate(req).await)
1959
+ }
1960
+ },
1961
+ agent_client_protocol::on_receive_request!(),
1962
+ )
1963
+ .on_receive_request(
1964
+ {
1965
+ let state = Arc::clone(&state);
1966
+ async move |req: LoadSessionRequest, responder, _cx: ConnectionTo<Client>| {
1967
+ handle_response(responder, state.handle_load_session(req).await)
1968
+ }
1969
+ },
1970
+ agent_client_protocol::on_receive_request!(),
1971
+ )
1972
+ .on_receive_request(
1973
+ {
1974
+ let state = Arc::clone(&state);
1975
+ async move |req: ForkSessionRequest, responder, _cx: ConnectionTo<Client>| {
1976
+ handle_response(responder, state.handle_fork_session(req).await)
1977
+ }
1978
+ },
1979
+ agent_client_protocol::on_receive_request!(),
1980
+ )
1981
+ .on_receive_request(
1982
+ {
1983
+ let state = Arc::clone(&state);
1984
+ async move |req: NewSessionRequest, responder, _cx: ConnectionTo<Client>| {
1985
+ handle_response(responder, state.handle_new_session(req).await)
1986
+ }
1987
+ },
1988
+ agent_client_protocol::on_receive_request!(),
1989
+ )
1990
+ .on_receive_request(
1991
+ {
1992
+ let state = Arc::clone(&state);
1993
+ async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| {
1994
+ handle_response(responder, state.handle_prompt(&cx, req).await)
1995
+ }
1996
+ },
1997
+ agent_client_protocol::on_receive_request!(),
1998
+ )
1999
+ .on_receive_request(
2000
+ {
2001
+ let state = Arc::clone(&state);
2002
+ async move |req: SetSessionConfigOptionRequest, responder, cx: ConnectionTo<Client>| {
2003
+ handle_response(
2004
+ responder,
2005
+ state.handle_set_session_config_option(&cx, req).await,
2006
+ )
2007
+ }
2008
+ },
2009
+ agent_client_protocol::on_receive_request!(),
2010
+ )
2011
+ .on_receive_notification(
2012
+ {
2013
+ let state = Arc::clone(&state);
2014
+ async move |notif: CancelNotification, _cx: ConnectionTo<Client>| {
2015
+ state.handle_cancel(notif).await
2016
+ }
2017
+ },
2018
+ agent_client_protocol::on_receive_notification!(),
2019
+ )
2020
+ .connect_to(transport)
2021
+ .await
2022
+ .map_err(|e| anyhow::anyhow!("ACP connection error: {e}"))?;
2023
2024
log::info!("siGit shutting down");
2025
Ok(())