93
ReadTextFileRequest, ReleaseTerminalRequest, RequestId, RequestPermissionOutcome,
94
RequestPermissionRequest, ResumeSessionRequest, ResumeSessionResponse,
95
SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionCloseCapabilities,
96
- SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
97
- SessionConfigValueId, SessionDeleteCapabilities, SessionForkCapabilities, SessionId,
98
- SessionInfo, SessionInfoUpdate, SessionListCapabilities, SessionMode, SessionModeState,
99
- SessionNotification, SessionResumeCapabilities, SessionUpdate, SetSessionConfigOptionRequest,
100
- SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, StopReason,
101
- Terminal, TerminalOutputRequest, ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus,
102
- ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, UsageUpdate,
103
- WaitForTerminalExitRequest, WriteTextFileRequest,
96
+ SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectGroup,
97
+ SessionConfigSelectOption, SessionConfigValueId, SessionDeleteCapabilities,
98
+ SessionForkCapabilities, SessionId, SessionInfo, SessionInfoUpdate, SessionListCapabilities,
99
+ SessionMode, SessionModeState, SessionNotification, SessionResumeCapabilities, SessionUpdate,
100
+ SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest,
101
+ SetSessionModeResponse, StopReason, Terminal, TerminalOutputRequest, ToolCall, ToolCallContent,
102
+ ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
103
+ UnstructuredCommandInput, UsageUpdate, WaitForTerminalExitRequest, WriteTextFileRequest,
104
};
105
use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
106
use onde::inference::{ChatEngine, GgufModelConfig};
4648
));
4649
}
4650
let message = if enabled {
4651
- "Local inference is on. On-device models are highlighted; pick one from Model."
4651
+ "Local inference is on. On-device models are selectable in Model."
4652
} else {
4653
- "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
4653
+ "Local inference is off. siGit Code Cloud tiers are selectable in Model."
4654
};
4655
self.send_system_status(cx, args.session_id.clone(), message)
4656
.ok();
4711
}
4712
}
4713
4714
+ // Both groups are listed, but only the one matching Local inference can
4715
+ // be picked. ACP has no disabled option, so refuse and answer with the
4716
+ // unchanged options so the picker snaps back.
4717
+ if model_kind_of(model_id) != models::active_inference_kind() {
4718
+ let message = match model_id.strip_prefix("sigit-cloud:") {
4719
+ Some(tier) => format!(
4720
+ "{} runs on siGit Code Cloud. Turn off Local inference to use it.",
4721
+ provider::tier_title(tier)
4722
+ ),
4723
+ None => {
4724
+ let name = models::local_picker_items()
4725
+ .into_iter()
4726
+ .find(|item| item.config.model_id == model_id)
4727
+ .map(|item| item.display_name)
4728
+ .unwrap_or_else(|| model_id.to_string());
4729
+ format!("{name} runs on-device. Turn on Local inference to use it.")
4730
+ }
4731
+ };
4732
+ self.send_system_status(cx, args.session_id.clone(), message)
4733
+ .ok();
4734
+ let current = self.current_model.lock().unwrap().clone();
4735
+ let config_options = build_config_options(¤t, &args.session_id.to_string());
4736
+ return Ok(SetSessionConfigOptionResponse::new(config_options));
4737
+ }
4738
+
4739
// ── siGit Code Cloud tier: no local load; sign-in gated ─────────────
4740
if let Some(tier) = model_id.strip_prefix("sigit-cloud:") {
4741
match self.switch_to_cloud_tier(tier).await {
5156
let items = models::build_model_picker_items();
5157
let active_kind = models::active_inference_kind();
5158
5134
- let options: Vec<SessionConfigSelectOption> = items
5159
+ let tagged: Vec<(models::InferenceKind, SessionConfigSelectOption)> = items
5160
.iter()
5161
.filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete)
5162
.map(|item| {
5163
let mut desc_parts = Vec::new();
5139
- // Mark options in the inactive mode so the active group reads as the
5140
- // recommended set (the list is already ordered active-group-first).
5164
+ // Options in the inactive group stay visible but cannot be picked;
5165
+ // `handle_set_session_config_option` refuses them.
5166
if item.source.kind() != active_kind {
5142
- desc_parts.push("inactive mode".to_string());
5167
+ desc_parts.push(
5168
+ match active_kind {
5169
+ models::InferenceKind::Local => "Unavailable while Local inference is on",
5170
+ models::InferenceKind::Cloud => "Unavailable while Local inference is off",
5171
+ }
5172
+ .to_string(),
5173
+ );
5174
}
5175
if item.tool_calling {
5176
desc_parts.push("tool calling".to_string());
5220
"{base_name} - {}{source_badge}",
5221
models::format_context_window(item.context_window_tokens)
5222
));
5192
- SessionConfigSelectOption::new(
5223
+ let option = SessionConfigSelectOption::new(
5224
SessionConfigValueId::new(item.config.model_id.as_str()),
5225
name,
5226
)
5196
- .description(description)
5227
+ .description(description);
5228
+ (item.source.kind(), option)
5229
})
5230
.collect();
5231
5232
+ // Two groups, the active one first. ACP has no disabled flag on an option,
5233
+ // so the inactive group is labelled and the agent refuses a pick from it.
5234
+ let mut groups: Vec<SessionConfigSelectGroup> = Vec::new();
5235
+ for kind in [active_kind, other_inference_kind(active_kind)] {
5236
+ let options: Vec<SessionConfigSelectOption> = tagged
5237
+ .iter()
5238
+ .filter(|(k, _)| *k == kind)
5239
+ .map(|(_, option)| option.clone())
5240
+ .collect();
5241
+ if options.is_empty() {
5242
+ continue;
5243
+ }
5244
+ let (id, base, hint) = match kind {
5245
+ models::InferenceKind::Local => (
5246
+ "sigit-on-device",
5247
+ "siGit Code on-device",
5248
+ "turn on Local inference to use",
5249
+ ),
5250
+ models::InferenceKind::Cloud => (
5251
+ "sigit-cloud",
5252
+ "siGit Code Cloud",
5253
+ "turn off Local inference to use",
5254
+ ),
5255
+ };
5256
+ let name = if kind == active_kind {
5257
+ format!("{base} (active)")
5258
+ } else {
5259
+ ascii_safe(&format!("{base} - {hint}"))
5260
+ };
5261
+ groups.push(SessionConfigSelectGroup::new(id, name, options));
5262
+ }
5263
+
5264
// Local Inference on/off toggle, modeled as a two-option select so panel-only
5265
// ACP clients (no slash commands) can flip the mode.
5266
let local_on = settings::local_inference_enabled();
5274
SessionConfigValueId::new(LOCAL_INFERENCE_ON),
5275
"Local".to_string(),
5276
)
5213
- .description("Run inference on-device; on-device models are highlighted".to_string()),
5277
+ .description("Run inference on-device; on-device models are selectable".to_string()),
5278
SessionConfigSelectOption::new(
5279
SessionConfigValueId::new(LOCAL_INFERENCE_OFF),
5280
"Cloud".to_string(),
5281
)
5218
- .description("Use siGit Code Cloud; cloud tiers are highlighted".to_string()),
5282
+ .description("Use siGit Code Cloud; cloud tiers are selectable".to_string()),
5283
];
5284
let local_option = if CLIENT_BOOLEAN_CONFIG_OPTIONS.load(Ordering::Relaxed) {
5285
SessionConfigOption::boolean(LOCAL_INFERENCE_CONFIG_ID, "Local inference", local_on)
5291
local_options,
5292
)
5293
}
5230
- .description("Toggle on-device inference; changes which models are highlighted");
5294
+ .description("Toggle on-device inference; changes which models are selectable");
5295
5296
// Permissions dropdown (issue #76): Manual (ask), Auto (run unattended),
5297
// Plan (research only).
5312
.category(SessionConfigOptionCategory::Mode)
5313
.description("How tool calls are approved for this session");
5314
5251
- if options.is_empty() {
5315
+ if groups.is_empty() {
5316
return vec![local_option, permission_option];
5317
}
5318
5319
let current_value = SessionConfigValueId::new(current_model_id);
5320
5321
vec![
5258
- SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
5322
+ SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, groups)
5323
.category(SessionConfigOptionCategory::Model)
5260
- .description("Select an on-device model or a siGit Code Cloud tier"),
5324
+ .description(
5325
+ "On-device models or siGit Code Cloud tiers, depending on Local inference",
5326
+ ),
5327
local_option,
5328
permission_option,
5329
]
5330
}
5331
5332
+fn other_inference_kind(kind: models::InferenceKind) -> models::InferenceKind {
5333
+ match kind {
5334
+ models::InferenceKind::Local => models::InferenceKind::Cloud,
5335
+ models::InferenceKind::Cloud => models::InferenceKind::Local,
5336
+ }
5337
+}
5338
+
5339
+/// Which inference group a picker model id belongs to.
5340
+fn model_kind_of(model_id: &str) -> models::InferenceKind {
5341
+ if model_id.starts_with("sigit-cloud:") {
5342
+ models::InferenceKind::Cloud
5343
+ } else {
5344
+ models::InferenceKind::Local
5345
+ }
5346
+}
5347
+
5348
/// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None
5349
fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
5350
let items = models::local_picker_items();
5840
SlashCommand::Local(value) => {
5841
let enabled = value.unwrap_or(!settings::local_inference_enabled());
5842
let message = match settings::set_local_inference(enabled) {
5761
- Ok(()) if enabled => "Local inference is on. On-device models are highlighted; \
5843
+ Ok(()) if enabled => "Local inference is on. On-device models are selectable; \
5844
pick one with /models."
5845
.to_string(),
5764
- Ok(()) => "Local inference is off. siGit Code Cloud tiers are highlighted; \
5846
+ Ok(()) => "Local inference is off. siGit Code Cloud tiers are selectable; \
5847
pick one with /models."
5848
.to_string(),
5849
Err(error) => format!("error: could not save local inference setting: {error}"),
8365
}
8366
}
8367
8368
+ #[test]
8369
+ fn model_kind_of_follows_the_cloud_prefix() {
8370
+ assert!(model_kind_of("sigit-cloud:oke") == models::InferenceKind::Cloud);
8371
+ assert!(model_kind_of("Qwen/Qwen3-4B-GGUF") == models::InferenceKind::Local);
8372
+ }
8373
+
8374
+ #[test]
8375
+ fn acp_model_picker_groups_by_inference_kind() {
8376
+ let _guard = crate::ENV_TEST_LOCK
8377
+ .lock()
8378
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
8379
+ let config = std::env::temp_dir().join(format!("sigit-groups-{}", uuid::Uuid::new_v4()));
8380
+ let saved: Vec<_> = ["SIGIT_CONFIG_DIR", "SIGIT_LOCAL_INFERENCE"]
8381
+ .into_iter()
8382
+ .map(|name| (name, std::env::var_os(name)))
8383
+ .collect();
8384
+ // SAFETY: serialized by ENV_TEST_LOCK and restored below.
8385
+ unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &config) };
8386
+ let outcome = std::panic::catch_unwind(acp_model_picker_groups_once);
8387
+ // SAFETY: serialized by ENV_TEST_LOCK.
8388
+ unsafe {
8389
+ for (name, value) in saved {
8390
+ match value {
8391
+ Some(value) => std::env::set_var(name, value),
8392
+ None => std::env::remove_var(name),
8393
+ }
8394
+ }
8395
+ }
8396
+ let _ = std::fs::remove_dir_all(&config);
8397
+ if let Err(panic) = outcome {
8398
+ std::panic::resume_unwind(panic);
8399
+ }
8400
+ }
8401
+
8402
+ fn acp_model_picker_groups_once() {
8403
+ fn model_option(current: &str) -> serde_json::Value {
8404
+ let options =
8405
+ serde_json::to_value(build_config_options_for(current, "t-groups")).unwrap();
8406
+ options
8407
+ .as_array()
8408
+ .unwrap()
8409
+ .iter()
8410
+ .find(|option| option["id"] == MODEL_CONFIG_ID)
8411
+ .expect("model config option")
8412
+ .clone()
8413
+ }
8414
+ fn ids(group: &serde_json::Value) -> Vec<&str> {
8415
+ group["options"]
8416
+ .as_array()
8417
+ .unwrap()
8418
+ .iter()
8419
+ .filter_map(|option| option["value"].as_str())
8420
+ .collect()
8421
+ }
8422
+
8423
+ // SAFETY: the caller holds ENV_TEST_LOCK.
8424
+ unsafe { std::env::set_var("SIGIT_LOCAL_INFERENCE", "off") };
8425
+ let model = model_option("sigit-cloud:oke");
8426
+ let groups = model["options"].as_array().expect("groups");
8427
+ assert_eq!(groups[0]["group"], "sigit-cloud");
8428
+ assert!(
8429
+ ids(&groups[0])
8430
+ .iter()
8431
+ .all(|id| id.starts_with("sigit-cloud:"))
8432
+ );
8433
+ if let Some(local) = groups.get(1) {
8434
+ assert_eq!(local["group"], "sigit-on-device");
8435
+ assert!(
8436
+ local["name"]
8437
+ .as_str()
8438
+ .unwrap()
8439
+ .contains("turn on Local inference")
8440
+ );
8441
+ for option in local["options"].as_array().unwrap() {
8442
+ assert!(
8443
+ option["description"]
8444
+ .as_str()
8445
+ .unwrap()
8446
+ .starts_with("Unavailable while Local inference is on")
8447
+ || option["description"]
8448
+ .as_str()
8449
+ .unwrap()
8450
+ .starts_with("Unavailable while Local inference is off")
8451
+ );
8452
+ }
8453
+ }
8454
+
8455
+ // SAFETY: the caller holds ENV_TEST_LOCK.
8456
+ unsafe { std::env::set_var("SIGIT_LOCAL_INFERENCE", "on") };
8457
+ let model = model_option("sigit-cloud:oke");
8458
+ let groups = model["options"].as_array().expect("groups");
8459
+ assert_eq!(groups[0]["group"], "sigit-on-device");
8460
+ assert!(
8461
+ !ids(&groups[0])
8462
+ .iter()
8463
+ .any(|id| id.starts_with("sigit-cloud:"))
8464
+ );
8465
+ let cloud = groups.get(1).expect("cloud group second");
8466
+ assert_eq!(cloud["group"], "sigit-cloud");
8467
+ // The current model stays selected even though it sits in the inactive group.
8468
+ assert_eq!(model["currentValue"], "sigit-cloud:oke");
8469
+ assert!(ids(cloud).contains(&"sigit-cloud:oke"));
8470
+ }
8471
+
8472
fn acp_config_option_labels_once() {
8473
let current = GgufModelConfig {
8474
model_id: "sigit-cloud:oke".to_string(),
8487
.expect("model config option");
8488
let model_names: Vec<&str> = model["options"]
8489
.as_array()
8304
- .expect("model select options")
8490
+ .expect("model select groups")
8491
.iter()
8492
+ .flat_map(|group| group["options"].as_array().expect("group options"))
8493
.filter_map(|option| option["name"].as_str())
8494
.collect();
8495
assert!(model_names.contains(&"Oke - 200K ctx"));