Style system message
Model and inference picker changes are UI state, not conversation. Render them as a completed think-kind tool call so they read as system status rather than assistant chat text. The sign-in prompt stays an assistant message, since it needs the user to act on it. Co-Authored-By: siGit Code <noreply@sigit.si>
Seto Elkahfi committed
Sep 6, 2026 at 16:06 UTC
dc85a6041b9e2f8bd2be40d739ee02ddd26e16f5
2 files changed
+151
-16
src/main.rs
+41
-10
@@ -849,6 +849,25 @@ impl SiGitAgent {
849
))
850
}
851
852
+ fn send_system_status(
853
+ &self,
854
+ cx: &ConnectionTo<Client>,
855
+ session_id: SessionId,
856
+ title: impl Into<String>,
857
+ ) -> agent_client_protocol::Result<()> {
858
+ cx.send_notification(SessionNotification::new(
859
+ session_id,
860
+ SessionUpdate::ToolCall(
861
+ ToolCall::new(
862
+ format!("system-status-{}", uuid::Uuid::new_v4()),
863
+ title.into(),
864
+ )
865
+ .kind(ToolKind::Think)
866
+ .status(ToolCallStatus::Completed),
867
+ ),
868
+ ))
869
+ }
870
+
871
/// Run one inference turn (`fut`) while concurrently forwarding any streamed
872
/// tokens to the editor. The sink receiver is drained as the future runs, so
873
/// chunks reach the client live rather than all at once when it resolves.
@@ -2162,7 +2181,7 @@ impl SiGitAgent {
2181
} else {
2182
"Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
2183
};
2165
- self.send_assistant_message(cx, args.session_id.clone(), format!("\n\n{message}"))
2184
+ self.send_system_status(cx, args.session_id.clone(), message)
2185
.ok();
2186
// Rebuild so the Model picker reflects the new emphasis/order.
2187
let current = self.current_model.lock().unwrap().clone();
@@ -2217,15 +2236,27 @@ impl SiGitAgent {
2236
2237
// ── siGit Code Cloud tier: no local load; sign-in gated ─────────────
2238
if let Some(tier) = model_id.strip_prefix("sigit-cloud:") {
2220
- let message = match self.switch_to_cloud_tier(tier).await {
2221
- Some(display_name) => format!("Switched to {display_name}."),
2222
- None => CLOUD_LOGIN_PROMPT.to_string(),
2223
- };
2224
- // Start on a fresh line: ACP clients concatenate consecutive
2225
- // agent-message chunks into one block, so without this the switch
2226
- // confirmation runs onto the end of the previous assistant message.
2227
- self.send_assistant_message(cx, args.session_id.clone(), format!("\n\n{message}"))
2228
- .ok();
2239
+ match self.switch_to_cloud_tier(tier).await {
2240
+ Some(display_name) => {
2241
+ self.send_system_status(
2242
+ cx,
2243
+ args.session_id.clone(),
2244
+ format!("Switched to {display_name}."),
2245
+ )
2246
+ .ok();
2247
+ }
2248
+ None => {
2249
+ // The picker response updates successful selections. When the
2250
+ // selection cannot apply because auth is missing, surface that
2251
+ // actionable state in the thread.
2252
+ self.send_assistant_message(
2253
+ cx,
2254
+ args.session_id.clone(),
2255
+ format!("\n\n{CLOUD_LOGIN_PROMPT}"),
2256
+ )
2257
+ .ok();
2258
+ }
2259
+ }
2260
2261
let current = self.current_model.lock().unwrap().clone();
2262
let config_options = build_model_config_options(¤t);
tests/acp_permissions.rs
+110
-6
@@ -283,19 +283,22 @@ impl AgentUnderTest {
283
}
284
}
285
286
- fn wait_for_prompt_updates(&mut self, id: u64) -> (Value, Vec<Value>) {
286
+ fn wait_for_response_with_updates(&mut self, id: u64) -> (Value, Vec<Value>) {
287
let mut updates = Vec::new();
288
let deadline = Instant::now() + TIMEOUT;
289
loop {
290
let remaining = deadline.saturating_duration_since(Instant::now());
291
let Ok(message) = self.incoming.recv_timeout(remaining) else {
292
- panic!("timed out waiting for the prompt response");
292
+ panic!("timed out waiting for response to request {id}");
293
};
294
if message["method"] == "session/update" {
295
updates.push(message["params"]["update"].clone());
296
}
297
if message["id"] == id && message.get("method").is_none() {
298
- assert!(message.get("error").is_none(), "prompt failed: {message}");
298
+ assert!(
299
+ message.get("error").is_none(),
300
+ "request {id} failed: {message}"
301
+ );
302
return (message, updates);
303
}
304
}
@@ -451,6 +454,107 @@ fn permission_round_trip_cancel_then_allow() {
454
let _ = std::fs::remove_dir_all(&scratch);
455
}
456
457
+#[test]
458
+fn successful_config_option_changes_are_rendered_as_system_status_cards() {
459
+ let endpoint = start_fake_endpoint(vec![]);
460
+
461
+ let scratch =
462
+ std::env::temp_dir().join(format!("sigit_acp_config_messages_{}", std::process::id()));
463
+ let config_dir = scratch.join("config");
464
+ let cwd = scratch.join("cwd");
465
+ std::fs::create_dir_all(&config_dir).unwrap();
466
+ std::fs::create_dir_all(&cwd).unwrap();
467
+ std::fs::write(
468
+ config_dir.join("credentials.toml"),
469
+ "access_token = \"tok_test\"\nemail = \"dev@sigit.si\"\n",
470
+ )
471
+ .unwrap();
472
+
473
+ let mut agent = spawn_agent(endpoint.port, &config_dir);
474
+
475
+ let id = agent.request(
476
+ "initialize",
477
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
478
+ );
479
+ agent.wait_for_response(id);
480
+
481
+ let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
482
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
483
+ .as_str()
484
+ .expect("session id")
485
+ .to_string();
486
+
487
+ let id = agent.request(
488
+ "session/set_config_option",
489
+ json!({
490
+ "sessionId": session_id,
491
+ "configId": "sigit-local-inference",
492
+ "value": "local-inference-off",
493
+ }),
494
+ );
495
+ let (response, updates) = agent.wait_for_response_with_updates(id);
496
+ assert!(
497
+ response["result"]["configOptions"].is_array(),
498
+ "config response should refresh picker options: {response}"
499
+ );
500
+ assert!(
501
+ !updates
502
+ .iter()
503
+ .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
504
+ "Local Inference picker changes are passive UI state, not chat text: {updates:?}"
505
+ );
506
+ let status = updates
507
+ .iter()
508
+ .find(|update| {
509
+ update["sessionUpdate"] == "tool_call"
510
+ && update["kind"] == "think"
511
+ && update["status"] == "completed"
512
+ })
513
+ .expect("Local Inference change should render as a system-style status card");
514
+ assert_eq!(
515
+ status["title"],
516
+ "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
517
+ );
518
+
519
+ let id = agent.request(
520
+ "session/set_config_option",
521
+ json!({
522
+ "sessionId": session_id,
523
+ "configId": "sigit-model",
524
+ "value": "sigit-cloud:mini",
525
+ }),
526
+ );
527
+ let (response, updates) = agent.wait_for_response_with_updates(id);
528
+ assert!(
529
+ response["result"]["configOptions"].is_array(),
530
+ "config response should refresh picker options: {response}"
531
+ );
532
+ assert!(
533
+ !updates
534
+ .iter()
535
+ .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
536
+ "successful model picker changes must not be rendered as assistant chat text: {updates:?}"
537
+ );
538
+ let status = updates
539
+ .iter()
540
+ .find(|update| {
541
+ update["sessionUpdate"] == "tool_call"
542
+ && update["kind"] == "think"
543
+ && update["status"] == "completed"
544
+ })
545
+ .expect("cloud tier change should render as a system-style status card");
546
+ assert!(
547
+ status["title"]
548
+ .as_str()
549
+ .unwrap_or_default()
550
+ .starts_with("Switched to siGit Code Cloud"),
551
+ "unexpected cloud switch status title: {status}"
552
+ );
553
+
554
+ drop(agent);
555
+ let _ = std::fs::remove_dir_all(&scratch);
556
+}
557
+
558
#[test]
559
fn a_slow_commands_progress_reaches_the_client_while_it_runs() {
560
let scratch = std::env::temp_dir().join(format!("sigit_acp_progress_{}", std::process::id()));
@@ -726,7 +830,7 @@ fn write_todos_reaches_the_client_as_an_acp_plan() {
830
"prompt": [{"type": "text", "text": "fix the ACP display"}],
831
}),
832
);
729
- let (response, updates) = agent.wait_for_prompt_updates(prompt_id);
833
+ let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
834
assert_eq!(response["result"]["stopReason"], "end_turn");
835
836
let plan = updates
@@ -796,7 +900,7 @@ fn write_todos_with_an_unknown_status_still_reaches_the_client() {
900
"prompt": [{"type": "text", "text": "plan the work"}],
901
}),
902
);
799
- let (_response, updates) = agent.wait_for_prompt_updates(prompt_id);
903
+ let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
904
905
let plan = updates
906
.iter()
@@ -847,7 +951,7 @@ fn unconvertible_write_todos_falls_back_to_a_tool_call_card() {
951
"prompt": [{"type": "text", "text": "plan the work"}],
952
}),
953
);
850
- let (_response, updates) = agent.wait_for_prompt_updates(prompt_id);
954
+ let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
955
956
assert!(
957
!updates