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(&current);
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