feat(acp): offer the permission modes as session modes (#175)

Manual, Auto and Plan were only reachable as the sigit-permission-mode config option. That is what the spec recommends now and what Zed reads, but a client that draws the older mode selector had nothing to show. session/new, session/load and session/fork now return the same three in modes, and session/set_mode switches between them. It changes the state the config option changes, then sends a config_option_update so a client showing both stays in step. An id that is not a mode is -32602. A mode change made any other way is announced with current_mode_update: the config option, /plan, /clear (which drops the session override) and /reload (which rereads the default from settings.toml). The mode ids are the config option's value ids. Both lists are built from one PERMISSION_MODES table and both setters go through apply_permission_mode, so the two cannot drift apart. The spec says a client that supports config options should ignore modes, so Zed should see no difference. I have not checked that in Zed. Closes #148 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 6, 2026 at 14:09 UTC 2d4aa371bb7d02ebe9517b3b6f57668c9dc4efe4
5 files changed +331 -64
.agents/AGENTS.md
+5 -1
@@ -268,7 +268,11 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
268 announced, because it sets the announced status: a call that will ask starts `pending`, the
269 permission request carries that call's own id, and an `in_progress` update follows approval.
270 That update also puts the card's title back, since the permission request overwrites it
271 - with the full arguments. Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
271 + with the full arguments. The Manual/Auto/Plan choice reaches an ACP client twice: as the
272 + `sigit-permission-mode` config option, and as session `modes` with `session/set_mode`, which
273 + the spec is retiring but some clients still render instead. Both are built from
274 + `PERMISSION_MODES` in `main.rs` and read the same state, so whatever changes the mode has to
275 + refresh both (`ConfigOptionUpdate` and `send_current_mode`). Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
276 serialized by `SiGitAgent::turn_lock` so the dispatch loop can route the client's permission
277 answer mid-turn — don't move them back inline, and don't await client requests from inline
278 handlers (deadlock).
.agents/skills/agent-client-protocol/SKILL.md
+1
@@ -205,6 +205,7 @@ the connection and the agent anymore. Don't reintroduce the mpsc forwarder patte
205 | `ListSessionsRequest` | `handle_list_sessions` | the editor's "Import Threads" picker; gated by `SessionListCapabilities` |
206 | `PromptRequest` | `handle_prompt` | the turn: parse blocks → slash commands or tool-calling loop |
207 | `SetSessionConfigOptionRequest` | `handle_set_session_config_option` | the Zed model picker — switches/downloads models |
208 +| `SetSessionModeRequest` | `handle_set_session_mode` | legacy mode selector; same Manual/Auto/Plan state as the Permissions config option |
209 | `CancelNotification` | `handle_cancel` | notification, no response |
210
211 Everything else is left to the SDK default (method not found).
CHANGELOG.md
+8
@@ -8,6 +8,14 @@
8 print `sigit <version>` on stdout and exit 0. Before, the flag was not
9 recognized and siGit Code started a session, which left a script that asked
10 for the version waiting.
11 +- **The permission modes are also offered as ACP session modes.** Manual, Auto
12 + and Plan were only reachable as a config option, which is what the protocol
13 + now recommends and what Zed reads. A client that draws the older mode
14 + selector saw nothing. `session/new`, `session/load` and `session/fork` now
15 + return the same three in `modes`, `session/set_mode` switches between them,
16 + and a change made any other way (the config option, `/plan`, `/clear`,
17 + `/reload`) is announced with `current_mode_update`. A client that reads
18 + config options ignores `modes`, so nothing changes there (#148).
19
20 - **File tools read and write through the editor when it offers to.** An ACP
21 client that advertises `fs.readTextFile` or `fs.writeTextFile` serves those
src/main.rs
+179 -63
@@ -73,8 +73,8 @@ 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, EmbeddedResourceResource, ForkSessionRequest,
77 - ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse,
76 + ConfigOptionUpdate, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResourceResource,
77 + ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse,
78 ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
79 McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
80 PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptCapabilities,
@@ -82,8 +82,9 @@ use agent_client_protocol::schema::v1::{
82 RequestPermissionRequest, SessionAdditionalDirectoriesCapabilities, SessionCapabilities,
83 SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
84 SessionConfigValueId, SessionForkCapabilities, SessionId, SessionInfo, SessionListCapabilities,
85 - SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
86 - SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallContent, ToolCallLocation,
85 + SessionMode, SessionModeState, SessionNotification, SessionUpdate,
86 + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest,
87 + SetSessionModeResponse, StopReason, ToolCall, ToolCallContent, ToolCallLocation,
88 ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
89 WriteTextFileRequest,
90 };
@@ -1352,6 +1353,20 @@ impl SiGitAgent {
1353 cx.send_notification(SessionNotification::new(session_id, update))
1354 }
1355
1356 + /// Tell a client that renders the legacy mode selector which mode is live.
1357 + /// Sent wherever the Permissions config option is refreshed for a reason
1358 + /// other than that client's own `session/set_mode`, so the two views of the
1359 + /// same state never disagree.
1360 + fn send_current_mode(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
1361 + let mode = current_permission_mode(&session_id.to_string());
1362 + self.send_tool_call_update(
1363 + cx,
1364 + session_id,
1365 + SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(mode)),
1366 + )
1367 + .ok();
1368 + }
1369 +
1370 fn send_plan_update(
1371 &self,
1372 cx: &ConnectionTo<Client>,
@@ -2195,7 +2210,9 @@ impl SiGitAgent {
2210 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &args.session_id.to_string());
2211 }
2212
2198 - Ok(LoadSessionResponse::new().config_options(config_options))
2213 + Ok(LoadSessionResponse::new()
2214 + .config_options(config_options)
2215 + .modes(build_session_modes(&args.session_id.to_string())))
2216 }
2217
2218 async fn handle_fork_session(
@@ -2267,7 +2284,10 @@ impl SiGitAgent {
2284 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &new_id.to_string());
2285 }
2286
2270 - Ok(ForkSessionResponse::new(new_id).config_options(config_options))
2287 + let modes = build_session_modes(&new_id.to_string());
2288 + Ok(ForkSessionResponse::new(new_id)
2289 + .config_options(config_options)
2290 + .modes(modes))
2291 }
2292
2293 async fn handle_new_session(
@@ -2305,7 +2325,10 @@ impl SiGitAgent {
2325 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &session_id.to_string());
2326 }
2327
2308 - Ok(NewSessionResponse::new(session_id).config_options(config_options))
2328 + let modes = build_session_modes(&session_id.to_string());
2329 + Ok(NewSessionResponse::new(session_id)
2330 + .config_options(config_options)
2331 + .modes(modes))
2332 }
2333
2334 async fn handle_prompt(
@@ -3293,6 +3316,8 @@ impl SiGitAgent {
3316 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
3317 )
3318 .ok();
3319 + // A reload rereads settings.toml, which can move the default mode.
3320 + self.send_current_mode(cx, session_id.clone());
3321 self.advertise_commands(cx, session_id.clone());
3322
3323 self.send_assistant_message(
@@ -3303,6 +3328,45 @@ impl SiGitAgent {
3328 .ok();
3329 }
3330
3331 + /// `session/set_mode`, the selector ACP had before config options. The
3332 + /// modes are the Permissions dropdown under another name, so this changes
3333 + /// the same state and then refreshes the dropdown for a client that shows
3334 + /// both.
3335 + async fn handle_set_session_mode(
3336 + &self,
3337 + cx: &ConnectionTo<Client>,
3338 + args: SetSessionModeRequest,
3339 + ) -> agent_client_protocol::Result<SetSessionModeResponse> {
3340 + log::info!(
3341 + "set_session_mode: session={}, mode_id={}",
3342 + args.session_id,
3343 + args.mode_id
3344 + );
3345 +
3346 + self.activate_session(&args.session_id).await?;
3347 +
3348 + let session_key = args.session_id.to_string();
3349 + let Some(message) = apply_permission_mode(&session_key, args.mode_id.0.as_ref()) else {
3350 + return Err(agent_client_protocol::Error::new(
3351 + -32602,
3352 + format!("unknown session mode: {}", args.mode_id),
3353 + ));
3354 + };
3355 + self.send_system_status(cx, args.session_id.clone(), message)
3356 + .ok();
3357 + let config_options = {
3358 + let current = self.current_model.lock().unwrap();
3359 + build_config_options(&current, &session_key)
3360 + };
3361 + self.send_tool_call_update(
3362 + cx,
3363 + args.session_id,
3364 + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
3365 + )
3366 + .ok();
3367 + Ok(SetSessionModeResponse::new())
3368 + }
3369 +
3370 async fn handle_set_session_config_option(
3371 &self,
3372 cx: &ConnectionTo<Client>,
@@ -3352,35 +3416,15 @@ impl SiGitAgent {
3416 // ── Permissions dropdown (issue #76) ────────────────────────────────
3417 if args.config_id.0.as_ref() == PERMISSION_MODE_CONFIG_ID {
3418 let session_key = args.session_id.to_string();
3355 - let message = match args.value.as_value_id().map(|v| v.0.as_ref()) {
3356 - Some(PERMISSION_MODE_MANUAL) => {
3357 - permissions::set_plan_mode(&session_key, false);
3358 - permissions::set_session_mode(
3359 - &session_key,
3360 - Some(settings::PermissionMode::Ask),
3361 - );
3362 - "Permissions: Manual — the agent asks before running mutating tools."
3363 - }
3364 - Some(PERMISSION_MODE_AUTO) => {
3365 - permissions::set_plan_mode(&session_key, false);
3366 - permissions::set_session_mode(
3367 - &session_key,
3368 - Some(settings::PermissionMode::Allow),
3369 - );
3370 - "Permissions: Auto — tools run without asking, subject to settings.toml rules."
3371 - }
3372 - Some(PERMISSION_MODE_PLAN) => {
3373 - permissions::set_plan_mode(&session_key, true);
3374 - "Permissions: Plan — the agent researches with read-only tools and presents \
3375 - a plan; edits and commands are blocked."
3376 - }
3377 - other => {
3378 - return Err(agent_client_protocol::Error::new(
3379 - -32602,
3380 - format!("unknown Permissions value: {other:?}"),
3381 - ));
3382 - }
3419 + let picked = args.value.as_value_id().map(|v| v.0.as_ref());
3420 + let Some(message) = picked.and_then(|id| apply_permission_mode(&session_key, id))
3421 + else {
3422 + return Err(agent_client_protocol::Error::new(
3423 + -32602,
3424 + format!("unknown Permissions value: {picked:?}"),
3425 + ));
3426 };
3427 + self.send_current_mode(cx, args.session_id.clone());
3428 self.send_system_status(cx, args.session_id.clone(), message)
3429 .ok();
3430 let current = self.current_model.lock().unwrap().clone();
@@ -3751,6 +3795,78 @@ const PERMISSION_MODE_MANUAL: &str = "permission-mode-manual";
3795 const PERMISSION_MODE_AUTO: &str = "permission-mode-auto";
3796 const PERMISSION_MODE_PLAN: &str = "permission-mode-plan";
3797
3798 +/// The Permissions modes as `(id, name, description)`, in display order. Both
3799 +/// the config option and the legacy `modes` selector are built from this, so
3800 +/// a client sees the same three entries whichever one it renders.
3801 +const PERMISSION_MODES: [(&str, &str, &str); 3] = [
3802 + (
3803 + PERMISSION_MODE_MANUAL,
3804 + "Manual",
3805 + "Ask before running mutating tools",
3806 + ),
3807 + (
3808 + PERMISSION_MODE_AUTO,
3809 + "Auto",
3810 + "Run tools without asking, subject to settings.toml rules",
3811 + ),
3812 + (
3813 + PERMISSION_MODE_PLAN,
3814 + "Plan",
3815 + "Research only; no edits or commands",
3816 + ),
3817 +];
3818 +
3819 +/// The Permissions mode governing a session right now. Derived, never
3820 +/// stored — see `permissions.rs`.
3821 +fn current_permission_mode(session_key: &str) -> &'static str {
3822 + if permissions::plan_mode(session_key) {
3823 + return PERMISSION_MODE_PLAN;
3824 + }
3825 + match permissions::effective_mode(session_key) {
3826 + settings::PermissionMode::Allow => PERMISSION_MODE_AUTO,
3827 + settings::PermissionMode::Ask | settings::PermissionMode::Deny => PERMISSION_MODE_MANUAL,
3828 + }
3829 +}
3830 +
3831 +/// Switch a session to one of [`PERMISSION_MODES`]. Returns the status line
3832 +/// to show the user, or `None` for an id that is not a mode.
3833 +fn apply_permission_mode(session_key: &str, mode_id: &str) -> Option<&'static str> {
3834 + match mode_id {
3835 + PERMISSION_MODE_MANUAL => {
3836 + permissions::set_plan_mode(session_key, false);
3837 + permissions::set_session_mode(session_key, Some(settings::PermissionMode::Ask));
3838 + Some("Permissions: Manual — the agent asks before running mutating tools.")
3839 + }
3840 + PERMISSION_MODE_AUTO => {
3841 + permissions::set_plan_mode(session_key, false);
3842 + permissions::set_session_mode(session_key, Some(settings::PermissionMode::Allow));
3843 + Some("Permissions: Auto — tools run without asking, subject to settings.toml rules.")
3844 + }
3845 + PERMISSION_MODE_PLAN => {
3846 + permissions::set_plan_mode(session_key, true);
3847 + Some(
3848 + "Permissions: Plan — the agent researches with read-only tools and presents \
3849 + a plan; edits and commands are blocked.",
3850 + )
3851 + }
3852 + _ => None,
3853 + }
3854 +}
3855 +
3856 +/// The same three modes as the Permissions config option, in the shape ACP
3857 +/// had before config options existed. The spec is retiring it, and a client
3858 +/// that reads config options is told to ignore it, so this is here for
3859 +/// clients that only render the mode selector (issue #148).
3860 +fn build_session_modes(session_key: &str) -> SessionModeState {
3861 + let modes = PERMISSION_MODES
3862 + .iter()
3863 + .map(|&(id, name, description)| {
3864 + SessionMode::new(id, name).description(description.to_string())
3865 + })
3866 + .collect();
3867 + SessionModeState::new(current_permission_mode(session_key), modes)
3868 +}
3869 +
3870 /// Replace non-ASCII chars so a downstream byte-index truncation can't split a
3871 /// multi-byte char. Zed slices the model-picker label at a fixed byte offset
3872 /// (`agent_ui/src/config_options.rs`) and panics — crashing the whole editor —
@@ -3866,34 +3982,15 @@ fn build_config_options(
3982 .description("Toggle on-device inference; changes which models are highlighted");
3983
3984 // Permissions dropdown (issue #76): Manual (ask), Auto (run unattended),
3869 - // Plan (research only). Derived, never stored — see `permissions.rs`.
3870 - let permission_current = SessionConfigValueId::new(if permissions::plan_mode(session_key) {
3871 - PERMISSION_MODE_PLAN
3872 - } else {
3873 - match permissions::effective_mode(session_key) {
3874 - settings::PermissionMode::Allow => PERMISSION_MODE_AUTO,
3875 - settings::PermissionMode::Ask | settings::PermissionMode::Deny => {
3876 - PERMISSION_MODE_MANUAL
3877 - }
3878 - }
3879 - });
3880 - let permission_options = vec![
3881 - SessionConfigSelectOption::new(
3882 - SessionConfigValueId::new(PERMISSION_MODE_MANUAL),
3883 - "Manual".to_string(),
3884 - )
3885 - .description("Ask before running mutating tools".to_string()),
3886 - SessionConfigSelectOption::new(
3887 - SessionConfigValueId::new(PERMISSION_MODE_AUTO),
3888 - "Auto".to_string(),
3889 - )
3890 - .description("Run tools without asking, subject to settings.toml rules".to_string()),
3891 - SessionConfigSelectOption::new(
3892 - SessionConfigValueId::new(PERMISSION_MODE_PLAN),
3893 - "Plan".to_string(),
3894 - )
3895 - .description("Research only; no edits or commands".to_string()),
3896 - ];
3985 + // Plan (research only).
3986 + let permission_current = SessionConfigValueId::new(current_permission_mode(session_key));
3987 + let permission_options: Vec<_> = PERMISSION_MODES
3988 + .iter()
3989 + .map(|&(id, name, description)| {
3990 + SessionConfigSelectOption::new(SessionConfigValueId::new(id), name.to_string())
3991 + .description(description.to_string())
3992 + })
3993 + .collect();
3994 let permission_option = SessionConfigOption::select(
3995 PERMISSION_MODE_CONFIG_ID,
3996 "Permissions",
@@ -4196,6 +4293,7 @@ async fn exec_slash_acp(
4293 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
4294 )
4295 .ok();
4296 + agent.send_current_mode(cx, session_id.clone());
4297 agent
4298 .send_assistant_message(
4299 cx,
@@ -4228,6 +4326,7 @@ async fn exec_slash_acp(
4326 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
4327 )
4328 .ok();
4329 + agent.send_current_mode(cx, session_id.clone());
4330 agent.send_assistant_message(cx, session_id, message).ok();
4331 }
4332 SlashCommand::Permissions => {
@@ -4962,6 +5061,23 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
5061 },
5062 agent_client_protocol::on_receive_request!(),
5063 )
5064 + .on_receive_request(
5065 + {
5066 + let state = Arc::clone(&state);
5067 + async move |req: SetSessionModeRequest, responder, cx: ConnectionTo<Client>| {
5068 + let state = Arc::clone(&state);
5069 + let task_cx = cx.clone();
5070 + cx.spawn(async move {
5071 + let _turn = state.turn_lock.lock().await;
5072 + handle_response(
5073 + responder,
5074 + state.handle_set_session_mode(&task_cx, req).await,
5075 + )
5076 + })
5077 + }
5078 + },
5079 + agent_client_protocol::on_receive_request!(),
5080 + )
5081 .on_receive_notification(
5082 {
5083 let state = Arc::clone(&state);
tests/acp_permissions.rs
+138
@@ -1668,3 +1668,141 @@ fn unconvertible_write_todos_falls_back_to_a_tool_call_card() {
1668 drop(agent);
1669 let _ = std::fs::remove_dir_all(&scratch);
1670 }
1671 +
1672 +// ── Session modes (issue #148) ──────────────────────────────────────────────
1673 +
1674 +/// The Permissions dropdown is also offered as ACP session modes, for a client
1675 +/// that draws the mode selector and not config options. Both are the same
1676 +/// state, so a change made through either has to show up in the other.
1677 +#[test]
1678 +fn session_modes_mirror_the_permissions_config_option() {
1679 + let endpoint = start_fake_endpoint(vec![
1680 + sse_tool_call("call_1", "run_command", "{\"command\":\"echo sigit-mode\"}"),
1681 + sse_text("planned"),
1682 + ]);
1683 +
1684 + let scratch = std::env::temp_dir().join(format!("sigit_acp_modes_{}", std::process::id()));
1685 + let config_dir = scratch.join("config");
1686 + let cwd = scratch.join("cwd");
1687 + std::fs::create_dir_all(&config_dir).unwrap();
1688 + std::fs::create_dir_all(&cwd).unwrap();
1689 +
1690 + let mut agent = spawn_agent(endpoint.port, &config_dir);
1691 +
1692 + let id = agent.request(
1693 + "initialize",
1694 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
1695 + );
1696 + agent.wait_for_response(id);
1697 +
1698 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1699 + let response = agent.wait_for_response(id);
1700 + let session_id = response["result"]["sessionId"]
1701 + .as_str()
1702 + .expect("session id")
1703 + .to_string();
1704 + let modes = &response["result"]["modes"];
1705 + assert_eq!(modes["currentModeId"], "permission-mode-manual");
1706 + let mode_ids: Vec<&str> = modes["availableModes"]
1707 + .as_array()
1708 + .expect("available modes")
1709 + .iter()
1710 + .filter_map(|mode| mode["id"].as_str())
1711 + .collect();
1712 + assert_eq!(
1713 + mode_ids,
1714 + [
1715 + "permission-mode-manual",
1716 + "permission-mode-auto",
1717 + "permission-mode-plan"
1718 + ]
1719 + );
1720 +
1721 + // set_mode changes the session and refreshes the config option.
1722 + let id = agent.request(
1723 + "session/set_mode",
1724 + json!({"sessionId": session_id, "modeId": "permission-mode-plan"}),
1725 + );
1726 + let (_response, updates) = agent.wait_for_response_with_updates(id);
1727 + let refreshed = updates
1728 + .iter()
1729 + .find(|update| update["sessionUpdate"] == "config_option_update")
1730 + .expect("set_mode refreshes the config options");
1731 + let permissions = refreshed["configOptions"]
1732 + .as_array()
1733 + .expect("config options")
1734 + .iter()
1735 + .find(|option| option["id"] == "sigit-permission-mode")
1736 + .expect("permissions config option");
1737 + assert_eq!(permissions["currentValue"], "permission-mode-plan");
1738 +
1739 + // Plan mode is really on: the mutating call is denied, not asked about.
1740 + let prompt_id = agent.request(
1741 + "session/prompt",
1742 + json!({
1743 + "sessionId": session_id,
1744 + "prompt": [{"type": "text", "text": "run the command"}],
1745 + }),
1746 + );
1747 + let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
1748 + assert_eq!(response["result"]["stopReason"], "end_turn");
1749 + {
1750 + let requests = endpoint.requests.lock().unwrap();
1751 + let messages = requests[1]["messages"].as_array().expect("messages");
1752 + let result = messages
1753 + .iter()
1754 + .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
1755 + .expect("tool result for the blocked call");
1756 + assert!(
1757 + !result["content"]
1758 + .as_str()
1759 + .unwrap_or_default()
1760 + .contains("sigit-mode"),
1761 + "plan mode must not run the command: {result}"
1762 + );
1763 + }
1764 +
1765 + // The config option changes the mode, and says so to the mode selector.
1766 + let id = agent.request(
1767 + "session/set_config_option",
1768 + json!({
1769 + "sessionId": session_id,
1770 + "configId": "sigit-permission-mode",
1771 + "value": "permission-mode-auto",
1772 + }),
1773 + );
1774 + let (_response, updates) = agent.wait_for_response_with_updates(id);
1775 + let current = updates
1776 + .iter()
1777 + .find(|update| update["sessionUpdate"] == "current_mode_update")
1778 + .expect("a config option change announces the new mode");
1779 + assert_eq!(current["currentModeId"], "permission-mode-auto");
1780 +
1781 + // So does /plan.
1782 + let prompt_id = agent.request(
1783 + "session/prompt",
1784 + json!({
1785 + "sessionId": session_id,
1786 + "prompt": [{"type": "text", "text": "/plan on"}],
1787 + }),
1788 + );
1789 + let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
1790 + let current = updates
1791 + .iter()
1792 + .find(|update| update["sessionUpdate"] == "current_mode_update")
1793 + .expect("/plan announces the new mode");
1794 + assert_eq!(current["currentModeId"], "permission-mode-plan");
1795 +
1796 + // An id that is not a mode is an invalid-params error, and changes nothing.
1797 + let id = agent.request(
1798 + "session/set_mode",
1799 + json!({"sessionId": session_id, "modeId": "permission-mode-yolo"}),
1800 + );
1801 + let response = agent.wait_for("the set_mode error", |message| {
1802 + message["id"] == id && message.get("method").is_none()
1803 + });
1804 + assert_eq!(response["error"]["code"], -32602, "{response}");
1805 +
1806 + drop(agent);
1807 + let _ = std::fs::remove_dir_all(&scratch);
1808 +}