71
// schema types moved under `schema::v1` in agent-client-protocol 1.0.
72
use agent_client_protocol::schema::ProtocolVersion;
73
use agent_client_protocol::schema::v1::{
74
- AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
75
- AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
76
- ConfigOptionUpdate, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResourceResource,
77
- ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse,
78
- ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
74
+ AgentAuthCapabilities, AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest,
75
+ AuthenticateResponse, AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate,
76
+ CancelNotification, ConfigOptionUpdate, ContentBlock, ContentChunk, CurrentModeUpdate,
77
+ EmbeddedResourceResource, ForkSessionRequest, ForkSessionResponse, Implementation,
78
+ InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse,
79
+ LoadSessionRequest, LoadSessionResponse, LogoutCapabilities, LogoutRequest, LogoutResponse,
80
McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
81
PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptCapabilities,
82
PromptRequest, PromptResponse, ReadTextFileRequest, RequestPermissionOutcome,
1825
.agent_capabilities(
1826
AgentCapabilities::default()
1827
.load_session(true)
1828
+ // Gates the editor's sign out button. Without it the
1829
+ // only way out of an account is typing `/logout`.
1830
+ .auth(AgentAuthCapabilities::new().logout(LogoutCapabilities::new()))
1831
// Lets the editor attach images to a prompt. The capability is fixed
1832
// for the connection while the model is not, so a prompt that
1833
// brings an image to a model that cannot read one is answered with
1905
}
1906
}
1907
1908
+ /// ACP `logout`, the editor's sign out button. It names no session: the
1909
+ /// account belongs to the process, so every open thread is signed out.
1910
+ async fn handle_logout(
1911
+ &self,
1912
+ cx: &ConnectionTo<Client>,
1913
+ _req: LogoutRequest,
1914
+ ) -> agent_client_protocol::Result<LogoutResponse> {
1915
+ let message = self.sign_out(cx).await;
1916
+ log::info!("logout: {message}");
1917
+ Ok(LogoutResponse::new())
1918
+ }
1919
+
1920
+ /// Clear the account session, move every thread off the cloud tiers, and
1921
+ /// push a fresh picker to each thread whose model changed. Returns the
1922
+ /// message to show. Shared by ACP `logout` and the `/logout` command.
1923
+ async fn sign_out(&self, cx: &ConnectionTo<Client>) -> String {
1924
+ let message = account::end_session().await;
1925
+ let local = default_local_model_config();
1926
+ for key in self.leave_cloud(&local).await {
1927
+ let config_options = build_config_options(&local, &key);
1928
+ self.send_tool_call_update(
1929
+ cx,
1930
+ SessionId::new(key),
1931
+ SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
1932
+ )
1933
+ .ok();
1934
+ }
1935
+ message
1936
+ }
1937
+
1938
+ /// Put every session that was on a siGit Code Cloud tier on `local`
1939
+ /// instead, and return their ids. The token is gone, so the tiers cannot
1940
+ /// serve them.
1941
+ ///
1942
+ /// Parked sessions are moved along with the live one. Left on a tier, a
1943
+ /// parked thread would fail to restore it on its next prompt and run on
1944
+ /// whatever model the previous thread left live.
1945
+ async fn leave_cloud(&self, local: &GgufModelConfig) -> Vec<String> {
1946
+ let on_cloud = |model_id: &str| model_id.starts_with("sigit-cloud:");
1947
+
1948
+ // Collected before the live backend moves: `reset_to_local_backend`
1949
+ // records the live session's new model, which would hide it here.
1950
+ let mut moved = Vec::new();
1951
+ for (key, state) in self.sessions.lock().unwrap().iter_mut() {
1952
+ if on_cloud(&state.model_id) {
1953
+ state.model_id = local.model_id.clone();
1954
+ moved.push(key.clone());
1955
+ }
1956
+ }
1957
+
1958
+ let live_on_cloud = on_cloud(&self.current_model.lock().unwrap().model_id);
1959
+ if live_on_cloud {
1960
+ *self.current_model.lock().unwrap() = local.clone();
1961
+ self.reset_to_local_backend().await;
1962
+ }
1963
+
1964
+ // A new thread opened in cloud mode would ask for the default tier.
1965
+ if live_on_cloud || !moved.is_empty() || !settings::local_inference_enabled() {
1966
+ let _ = settings::set_local_inference(true);
1967
+ }
1968
+ moved
1969
+ }
1970
+
1971
/// Build the state for a session request: its primary `cwd` plus the extra
1972
/// roots worth keeping (see `workspace::set_additional_roots`).
1973
fn session_state(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> SessionState {
4775
agent.send_assistant_message(cx, session_id, message).ok();
4776
}
4777
SlashCommand::Logout => {
4711
- // If we're on a cloud tier, drop back to local — the token is gone.
4712
- let on_cloud = {
4713
- let guard = agent.current_model.lock().unwrap();
4714
- guard.model_id.starts_with("sigit-cloud:")
4715
- };
4716
- let message = account::end_session().await;
4717
- if on_cloud {
4718
- *agent.current_model.lock().unwrap() = default_local_model_config();
4719
- agent.reset_to_local_backend().await;
4720
- let _ = settings::set_local_inference(true);
4721
- }
4778
+ let message = agent.sign_out(cx).await;
4779
agent.send_assistant_message(cx, session_id, message).ok();
4780
}
4781
SlashCommand::Whoami => {
5196
// while the handler blocks it. Requests on one session keep their
5197
// order through that session's lock; whatever touches process-wide
5198
// state takes `workspace_lock` (a prompt does so itself, in pieces).
5199
+ .on_receive_request(
5200
+ {
5201
+ let state = Arc::clone(&state);
5202
+ async move |req: LogoutRequest, responder, cx: ConnectionTo<Client>| {
5203
+ let state = Arc::clone(&state);
5204
+ let task_cx = cx.clone();
5205
+ cx.spawn(async move {
5206
+ let _workspace = state.workspace_lock.lock().await;
5207
+ handle_response(responder, state.handle_logout(&task_cx, req).await)
5208
+ })
5209
+ }
5210
+ },
5211
+ agent_client_protocol::on_receive_request!(),
5212
+ )
5213
.on_receive_request(
5214
{
5215
let state = Arc::clone(&state);
5745
.expect("cancel notification should remain available to the prompt task");
5746
}
5747
5748
+ #[tokio::test]
5749
+ #[allow(clippy::await_holding_lock)] // the lock guards process-global cwd/env for the whole test
5750
+ async fn signing_out_moves_every_cloud_session_on_device() {
5751
+ let _guard = crate::ENV_TEST_LOCK
5752
+ .lock()
5753
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
5754
+ let temp = std::env::temp_dir().join(format!("sigit-logout-{}", uuid::Uuid::new_v4()));
5755
+ let (repo_a, repo_b, repo_c) = (
5756
+ temp.join("repo-a"),
5757
+ temp.join("repo-b"),
5758
+ temp.join("repo-c"),
5759
+ );
5760
+ for dir in [&repo_a, &repo_b, &repo_c] {
5761
+ std::fs::create_dir_all(dir).unwrap();
5762
+ }
5763
+ let prev_cwd = std::env::current_dir().unwrap();
5764
+ // SAFETY: serialized by ENV_TEST_LOCK and restored below.
5765
+ unsafe {
5766
+ std::env::set_var("SIGIT_CONFIG_DIR", temp.join("config"));
5767
+ std::env::set_var("SIGIT_LOCAL_INFERENCE", "on");
5768
+ }
5769
+
5770
+ let local = default_local_model_config();
5771
+ let agent = SiGitAgent::new(
5772
+ Arc::new(ChatEngine::new()),
5773
+ local.clone(),
5774
+ Arc::new(AtomicBool::new(true)),
5775
+ Arc::new(AtomicBool::new(false)),
5776
+ Arc::new(std::sync::Mutex::new(None)),
5777
+ false,
5778
+ false,
5779
+ );
5780
+ let cloud = || provider::ProviderConfig {
5781
+ display_name: "test cloud".into(),
5782
+ base_url: "http://127.0.0.1:9".into(),
5783
+ api_key: String::new(),
5784
+ model: "test".into(),
5785
+ };
5786
+
5787
+ // Thread A is parked on a cloud tier, thread B stays on-device, and
5788
+ // thread C is live on a cloud tier with a turn in its history.
5789
+ for (id, repo) in [
5790
+ ("thread-a", &repo_a),
5791
+ ("thread-b", &repo_b),
5792
+ ("thread-c", &repo_c),
5793
+ ] {
5794
+ agent
5795
+ .open_session(&SessionId::new(id), agent.session_state(repo, &[]))
5796
+ .await;
5797
+ }
5798
+ agent
5799
+ .sessions
5800
+ .lock()
5801
+ .unwrap()
5802
+ .get_mut("thread-a")
5803
+ .unwrap()
5804
+ .model_id = "sigit-cloud:a".into();
5805
+ agent
5806
+ .install_remote_backend(cloud(), "sigit-cloud:c".into())
5807
+ .await;
5808
+ say(&agent, "working on repo c").await;
5809
+ // The cloud tiers are in use, so the stored toggle reads "cloud".
5810
+ // SAFETY: serialized by ENV_TEST_LOCK.
5811
+ unsafe {
5812
+ std::env::remove_var("SIGIT_LOCAL_INFERENCE");
5813
+ }
5814
+ settings::set_local_inference(false).unwrap();
5815
+
5816
+ let mut moved = agent.leave_cloud(&local).await;
5817
+ moved.sort();
5818
+ assert_eq!(moved, ["thread-a", "thread-c"]);
5819
+
5820
+ assert!(!agent.backend.lock().await.is_remote());
5821
+ assert_eq!(agent.current_model.lock().unwrap().model_id, local.model_id);
5822
+ for state in agent.sessions.lock().unwrap().values() {
5823
+ assert_eq!(state.model_id, local.model_id);
5824
+ }
5825
+ assert!(settings::local_inference_enabled());
5826
+ // The thread carries on where the cloud tier left it.
5827
+ let history = history_text(&live(&agent).await);
5828
+ assert!(history.contains("working on repo c"), "{history}");
5829
+
5830
+ // Nothing is left to move the second time.
5831
+ assert!(agent.leave_cloud(&local).await.is_empty());
5832
+
5833
+ std::env::set_current_dir(prev_cwd).unwrap();
5834
+ tools::set_active_session(None);
5835
+ workspace::replace_additional_roots(Vec::new());
5836
+ // SAFETY: serialized by ENV_TEST_LOCK.
5837
+ unsafe {
5838
+ std::env::remove_var("SIGIT_CONFIG_DIR");
5839
+ }
5840
+ std::fs::remove_dir_all(&temp).ok();
5841
+ }
5842
+
5843
#[tokio::test]
5844
#[allow(clippy::await_holding_lock)] // the lock guards process-global cwd/env for the whole test
5845
async fn each_session_keeps_its_own_roots_and_conversation() {