@setoelkahfi / sigit / commits / c7c34ff

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(&current_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(&current_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(())