Skip to main content

starweaver_cli/
rpc.rs

1//! JSON-RPC host service and local transports.
2
3mod environment_manager;
4mod transport;
5
6use std::{
7    collections::HashMap,
8    sync::{
9        atomic::{AtomicBool, Ordering},
10        mpsc, Arc, Mutex,
11    },
12    thread,
13    time::Duration,
14};
15
16use self::environment_manager::EnvironmentAttachmentManager;
17use serde_json::{json, Value};
18use starweaver_rpc_core::{
19    attachment_result, environment_attachment_refs, environment_attachment_result,
20    handle_json_rpc_text_async, notification, output_item, replay_cursor_from_params,
21    replay_result, stream_payload_format, RpcError, StreamPayloadFormat, INVALID_PARAMS,
22    METHOD_NOT_FOUND, SERVER_ERROR, UNSUPPORTED_FEATURE,
23};
24use starweaver_stream::ReplayScope;
25use tokio::runtime::Runtime;
26use uuid::Uuid;
27
28use crate::{
29    args::{HitlPolicy, OutputMode, RpcCommand, RpcTransport, RunCommand},
30    client_state,
31    config::{get_config_value, read_current_session, write_current_session, CliConfig},
32    local_store::{LocalStore, LocalStreamArchive},
33    profiles::{list_config_model_profiles, list_profiles, show_profile},
34    runner::CliSteeringMessage,
35    runtime_coordinator::{CliRuntimeCoordinator, RunAttachment, RunStreamEvent, StartedRun},
36    CliError, CliResult, CliService,
37};
38
39const PROTOCOL_VERSION: &str = "2026-06-08";
40
41impl From<CliError> for RpcError {
42    fn from(error: CliError) -> Self {
43        Self::new(SERVER_ERROR, error.to_string())
44    }
45}
46
47/// Run the selected JSON-RPC host transport.
48pub fn run(config: &CliConfig, command: &RpcCommand) -> CliResult<()> {
49    match command.transport {
50        RpcTransport::Stdio => transport::run_stdio(config),
51        RpcTransport::Http => transport::run_http(config, &command.host, command.port),
52    }
53}
54
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
56enum RpcNotificationMode {
57    Live,
58    ReplayOnly,
59}
60
61impl RpcNotificationMode {
62    const fn supports_live_notifications(self) -> bool {
63        matches!(self, Self::Live)
64    }
65}
66
67struct RpcService {
68    config: CliConfig,
69    coordinator: CliRuntimeCoordinator,
70    output_sender: mpsc::Sender<Value>,
71    subscriptions: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
72    notifications: RpcNotificationMode,
73    runtime: Arc<Runtime>,
74    environment_manager: EnvironmentAttachmentManager,
75}
76
77impl RpcService {
78    fn new(config: CliConfig, output_sender: mpsc::Sender<Value>) -> Self {
79        Self::with_notification_mode(config, output_sender, RpcNotificationMode::Live)
80    }
81
82    fn replay_only(config: CliConfig, output_sender: mpsc::Sender<Value>) -> Self {
83        Self::with_notification_mode(config, output_sender, RpcNotificationMode::ReplayOnly)
84    }
85
86    fn with_notification_mode(
87        config: CliConfig,
88        output_sender: mpsc::Sender<Value>,
89        notifications: RpcNotificationMode,
90    ) -> Self {
91        let environment_manager = EnvironmentAttachmentManager::new();
92        Self {
93            coordinator: CliRuntimeCoordinator::new(config.clone()),
94            config,
95            output_sender,
96            subscriptions: Arc::new(Mutex::new(HashMap::new())),
97            notifications,
98            environment_manager,
99            runtime: build_rpc_runtime(),
100        }
101    }
102
103    fn handle_line(&self, line: &str) -> (Option<Value>, bool) {
104        self.handle_text(line)
105    }
106
107    fn handle_text(&self, text: &str) -> (Option<Value>, bool) {
108        let outcome = self.runtime.block_on(self.handle_text_async(text));
109        (outcome.response, outcome.shutdown)
110    }
111
112    async fn handle_text_async(&self, text: &str) -> starweaver_rpc_core::JsonRpcOutcome {
113        handle_json_rpc_text_async(text, |method, params| async move {
114            self.dispatch_async(&method, &params).await
115        })
116        .await
117    }
118
119    #[allow(clippy::too_many_lines)]
120    async fn dispatch_async(&self, method: &str, params: &Value) -> Result<Value, RpcError> {
121        let config = &self.config;
122        match method {
123            "initialize" => Ok(initialize_result(config, self.notifications)),
124            "shutdown" => Ok(json!({"status": "shutdown"})),
125            "profile.list" => Ok(json!({
126                "profiles": list_profiles(config),
127                "current": selected_profile_result(config, params.get("client").and_then(Value::as_str))?,
128            })),
129            "model.list" => Ok(json!({
130                "profiles": list_config_model_profiles(config),
131                "current": selected_model_profile_result(config, params.get("client").and_then(Value::as_str))?,
132            })),
133            "profile.get" => {
134                let name = required_string(params, "name")?;
135                let yaml = show_profile(config, &name).map_err(RpcError::from)?;
136                Ok(json!({"name": name, "profile": yaml}))
137            }
138            "model.current" => {
139                selected_model_profile_result(config, params.get("client").and_then(Value::as_str))
140            }
141            "model.select" => {
142                let profile = required_string(params, "profile")?;
143                ensure_client_model_profile(config, &profile)?;
144                let client = params
145                    .get("client")
146                    .and_then(Value::as_str)
147                    .unwrap_or("tui");
148                client_state::write_selected_profile(config, client, &profile)
149                    .map_err(RpcError::from)?;
150                Ok(json!({
151                    "client": client,
152                    "selectedProfile": profile,
153                    "modelId": model_id_for_profile(config, &profile),
154                }))
155            }
156            "config.get" => config_get(config, params),
157            "diagnostics.get" => Ok(json!({
158                "sdk": starweaver_core::sdk_name(),
159                "version": env!("CARGO_PKG_VERSION"),
160                "globalDir": config.global_dir,
161                "projectDir": config.project_dir,
162                "tuiStateDir": config.tui_state_dir,
163                "desktopStateDir": config.desktop_state_dir,
164                "databasePath": config.database_path,
165                "defaultProfile": config.default_profile,
166                "profiles": list_profiles(config).len(),
167            })),
168            "session.create" => {
169                let profile = params
170                    .get("profile")
171                    .and_then(Value::as_str)
172                    .unwrap_or(&config.default_profile);
173                let title = params
174                    .get("title")
175                    .and_then(Value::as_str)
176                    .map(ToString::to_string);
177                let mut store = LocalStore::open(config).map_err(RpcError::from)?;
178                let session = store
179                    .create_session(profile, title)
180                    .map_err(RpcError::from)?;
181                Ok(json!({"session": session}))
182            }
183            "session.list" => {
184                let limit = params
185                    .get("limit")
186                    .and_then(Value::as_u64)
187                    .and_then(|value| usize::try_from(value).ok())
188                    .unwrap_or(50);
189                let store = LocalStore::open(config).map_err(RpcError::from)?;
190                let sessions = store.list_sessions(limit).map_err(RpcError::from)?;
191                Ok(json!({"sessions": sessions}))
192            }
193            "session.get" => {
194                let session_id = required_string(params, "sessionId")?;
195                let runs_limit = params
196                    .get("runs")
197                    .and_then(Value::as_u64)
198                    .and_then(|value| usize::try_from(value).ok())
199                    .unwrap_or(20);
200                let store = LocalStore::open(config).map_err(RpcError::from)?;
201                let session = store.load_session(&session_id).map_err(RpcError::from)?;
202                let runs = store
203                    .list_runs(&session_id, runs_limit)
204                    .map_err(RpcError::from)?;
205                Ok(json!({"session": session, "runs": runs}))
206            }
207            "session.current.get" => Ok(json!({
208                "sessionId": read_current_session(config).map_err(RpcError::from)?,
209            })),
210            "session.current.set" => {
211                let session_id = required_string(params, "sessionId")?;
212                write_current_session(config, &session_id).map_err(RpcError::from)?;
213                Ok(json!({"sessionId": session_id}))
214            }
215            "session.replay" | "stream.replay" => self.stream_replay(params),
216            "session.delete" => {
217                let session_id = required_string(params, "sessionId")?;
218                let mut store = LocalStore::open(config).map_err(RpcError::from)?;
219                let deleted = store.delete_session(&session_id).map_err(RpcError::from)?;
220                Ok(json!({"sessionId": session_id, "deleted": deleted}))
221            }
222            "session.output" => self.session_output(params),
223            "stream.subscribe" => self.stream_subscribe(params),
224            "stream.unsubscribe" => self.stream_unsubscribe(params),
225            "run.prompt" => self.run_prompt(params).await,
226            "run.start" => self.run_start(params).await,
227            "run.attach" => self.run_attach(params),
228            "run.status" => self.run_status(params),
229            "run.await" => self.run_await(params),
230            "run.cancel" => self.run_cancel(params),
231            "run.steer" => self.run_steer(params),
232            "session.steer" => self.session_steer(params),
233            "approval.list" => {
234                let store = LocalStore::open(config).map_err(RpcError::from)?;
235                let records = store
236                    .list_approvals(
237                        params.get("sessionId").and_then(Value::as_str),
238                        params.get("runId").and_then(Value::as_str),
239                    )
240                    .map_err(RpcError::from)?;
241                Ok(json!({"approvals": records}))
242            }
243            "approval.show" => {
244                let approval_id = required_string(params, "approvalId")?;
245                let store = LocalStore::open(config).map_err(RpcError::from)?;
246                let approval = store.load_approval(&approval_id).map_err(RpcError::from)?;
247                Ok(json!({"approval": approval}))
248            }
249            "approval.decide" => {
250                let approval_id = required_string(params, "approvalId")?;
251                let status = match required_string(params, "status")?.as_str() {
252                    "approved" | "approve" => starweaver_session::ApprovalStatus::Approved,
253                    "denied" | "rejected" | "reject" => starweaver_session::ApprovalStatus::Denied,
254                    other => {
255                        return Err(RpcError::new(
256                            INVALID_PARAMS,
257                            format!("unknown approval status: {other}"),
258                        ))
259                    }
260                };
261                let reason = params
262                    .get("reason")
263                    .and_then(Value::as_str)
264                    .map(ToString::to_string);
265                let mut store = LocalStore::open(config).map_err(RpcError::from)?;
266                let approval = store
267                    .decide_approval(&approval_id, status, reason)
268                    .map_err(RpcError::from)?;
269                Ok(json!({"approval": approval}))
270            }
271            "deferred.list" => {
272                let store = LocalStore::open(config).map_err(RpcError::from)?;
273                let records = store
274                    .list_deferred_tools(
275                        params.get("sessionId").and_then(Value::as_str),
276                        params.get("runId").and_then(Value::as_str),
277                    )
278                    .map_err(RpcError::from)?;
279                Ok(json!({"deferred": records}))
280            }
281            "deferred.show" => {
282                let deferred_id = required_string(params, "deferredId")?;
283                let store = LocalStore::open(config).map_err(RpcError::from)?;
284                let deferred = store
285                    .load_deferred_tool(&deferred_id)
286                    .map_err(RpcError::from)?;
287                Ok(json!({"deferred": deferred}))
288            }
289            "deferred.complete" => {
290                let deferred_id = required_string(params, "deferredId")?;
291                let result = params.get("result").cloned().unwrap_or(Value::Null);
292                let mut store = LocalStore::open(config).map_err(RpcError::from)?;
293                let deferred = store
294                    .complete_deferred_tool(&deferred_id, result)
295                    .map_err(RpcError::from)?;
296                Ok(json!({"deferred": deferred}))
297            }
298            "deferred.fail" => {
299                let deferred_id = required_string(params, "deferredId")?;
300                let error = required_string(params, "error")?;
301                let mut store = LocalStore::open(config).map_err(RpcError::from)?;
302                let deferred = store
303                    .fail_deferred_tool(&deferred_id, &error)
304                    .map_err(RpcError::from)?;
305                Ok(json!({"deferred": deferred}))
306            }
307            "environment.attach" => {
308                self.validate_environment_attach_scope(params)?;
309                self.environment_manager.attach(params).await
310            }
311            "environment.detach" => self.environment_manager.detach(params),
312            "environment.list" => self.environment_manager.list(params),
313            "environment.health" => self.environment_manager.health(params).await,
314            other => Err(RpcError::new(
315                METHOD_NOT_FOUND,
316                format!("method not found: {other}"),
317            )),
318        }
319    }
320
321    async fn run_start(&self, params: &Value) -> Result<Value, RpcError> {
322        let format = stream_payload_format(params)?;
323        let mut command = run_command_from_params(&self.config, params, OutputMode::Json)?;
324        let run_session_id = command.session.clone();
325        command.environment_attachments = self
326            .environment_manager
327            .materialize_run_attachments(command.environment_attachments, run_session_id.as_deref())
328            .await?;
329        let environment_attachments = command.environment_attachments.clone();
330        let pending_environment_run_id = pending_active_environment_run_id();
331        self.environment_manager
332            .mark_run_started(&pending_environment_run_id, &environment_attachments)?;
333        let started = match self
334            .coordinator
335            .start_run(command, None)
336            .map_err(RpcError::from)
337        {
338            Ok(started) => started,
339            Err(error) => {
340                self.environment_manager
341                    .mark_run_finished(&pending_environment_run_id)?;
342                return Err(error);
343            }
344        };
345        let session_id = started.session_id.clone();
346        let run_id = started.run_id.clone();
347        if let Err(error) = self
348            .environment_manager
349            .mark_run_started(&run_id, &environment_attachments)
350        {
351            let _ = self
352                .environment_manager
353                .mark_run_finished(&pending_environment_run_id);
354            return Err(error);
355        }
356        self.environment_manager
357            .mark_run_finished(&pending_environment_run_id)?;
358        self.spawn_run_notifications(started, format);
359        let mut result = json!({
360            "sessionId": session_id,
361            "runId": run_id,
362            "status": "running",
363            "payloadFormat": format.as_str(),
364        });
365        if !environment_attachments.is_empty() {
366            merge_object(
367                &mut result,
368                &environment_attachment_result(&environment_attachments),
369            );
370        }
371        Ok(result)
372    }
373
374    async fn run_prompt(&self, params: &Value) -> Result<Value, RpcError> {
375        let mut command = run_command_from_params(&self.config, params, OutputMode::Json)?;
376        let run_session_id = command.session.clone();
377        command.environment_attachments = self
378            .environment_manager
379            .materialize_run_attachments(command.environment_attachments, run_session_id.as_deref())
380            .await?;
381        let active_environment_run_id = prompt_active_environment_run_id();
382        self.environment_manager
383            .mark_run_started(&active_environment_run_id, &command.environment_attachments)?;
384        let config = self.config.clone();
385        let join_result =
386            tokio::task::spawn_blocking(move || CliService::open(config)?.run_prompt(&command))
387                .await;
388        let finish_result = self
389            .environment_manager
390            .mark_run_finished(&active_environment_run_id);
391        let output_result = match join_result {
392            Ok(result) => result.map_err(RpcError::from),
393            Err(error) => Err(RpcError::new(SERVER_ERROR, error.to_string())),
394        };
395        let output = output_result?;
396        finish_result?;
397        serde_json::from_str(output.trim())
398            .map_err(|error| RpcError::new(SERVER_ERROR, error.to_string()))
399    }
400
401    fn run_attach(&self, params: &Value) -> Result<Value, RpcError> {
402        let session_id = required_string(params, "sessionId")?;
403        let run_id = required_string(params, "runId")?;
404        let format = stream_payload_format(params)?;
405        let cursor = replay_cursor_from_params(params, ReplayScope::run(&run_id))?;
406        let mut attachment = self
407            .coordinator
408            .attach_run(&session_id, &run_id, cursor.as_ref())
409            .map_err(RpcError::from)?;
410        let result = attachment_result(
411            &attachment.session_id,
412            attachment.run_id.as_deref(),
413            attachment.active,
414            &attachment.events,
415            format,
416        );
417        self.spawn_attachment_notifications(&mut attachment, format);
418        Ok(result)
419    }
420
421    fn session_output(&self, params: &Value) -> Result<Value, RpcError> {
422        let session_id = required_string(params, "sessionId")?;
423        let run_id = params.get("runId").and_then(Value::as_str);
424        let format = stream_payload_format(params)?;
425        let scope = run_id.map_or_else(|| ReplayScope::session(&session_id), ReplayScope::run);
426        let cursor = replay_cursor_from_params(params, scope)?;
427        let mut attachment = self
428            .coordinator
429            .session_output(&session_id, run_id, cursor.as_ref())
430            .map_err(RpcError::from)?;
431        let result = attachment_result(
432            &attachment.session_id,
433            attachment.run_id.as_deref(),
434            attachment.active,
435            &attachment.events,
436            format,
437        );
438        self.spawn_attachment_notifications(&mut attachment, format);
439        Ok(result)
440    }
441
442    fn stream_replay(&self, params: &Value) -> Result<Value, RpcError> {
443        let session_id = required_string(params, "sessionId")?;
444        let run_id = params.get("runId").and_then(Value::as_str);
445        let scope = run_id.map_or_else(|| ReplayScope::session(&session_id), ReplayScope::run);
446        let cursor = replay_cursor_from_params(params, scope)?;
447        let archive = LocalStreamArchive::new(self.config.clone());
448        let window = archive
449            .replay_display_window(&session_id, run_id, cursor.as_ref())
450            .map_err(RpcError::from)?;
451        Ok(replay_result(
452            &session_id,
453            run_id,
454            &window.scope,
455            &window.events,
456            cursor.as_ref(),
457            window.next_sequence,
458        ))
459    }
460
461    fn stream_subscribe(&self, params: &Value) -> Result<Value, RpcError> {
462        if !self.notifications.supports_live_notifications() {
463            return Err(RpcError::new(
464                UNSUPPORTED_FEATURE,
465                "stream.subscribe requires a live notification transport",
466            ));
467        }
468        let subscription_id = subscription_id(params);
469        let session_id = required_string(params, "sessionId")?;
470        let run_id = params.get("runId").and_then(Value::as_str);
471        let format = stream_payload_format(params)?;
472        let scope = run_id.map_or_else(|| ReplayScope::session(&session_id), ReplayScope::run);
473        let cursor = replay_cursor_from_params(params, scope)?;
474        let mut attachment = if let Some(run_id) = run_id {
475            self.coordinator
476                .attach_run(&session_id, run_id, cursor.as_ref())
477                .map_err(RpcError::from)?
478        } else {
479            self.coordinator
480                .session_output(&session_id, None, cursor.as_ref())
481                .map_err(RpcError::from)?
482        };
483        let mut result = attachment_result(
484            &attachment.session_id,
485            attachment.run_id.as_deref(),
486            attachment.active,
487            &attachment.events,
488            format,
489        );
490        insert_subscription_id(&mut result, &subscription_id);
491        if attachment.subscription.is_some() {
492            let cancel = Arc::new(AtomicBool::new(false));
493            let mut subscriptions = self.subscriptions.lock().map_err(|error| {
494                RpcError::new(
495                    SERVER_ERROR,
496                    format!("subscription registry poisoned: {error}"),
497                )
498            })?;
499            if subscriptions.contains_key(&subscription_id) {
500                return Err(RpcError::new(
501                    SERVER_ERROR,
502                    format!("subscription already exists: {subscription_id}"),
503                ));
504            }
505            subscriptions.insert(subscription_id.clone(), Arc::clone(&cancel));
506            drop(subscriptions);
507            self.spawn_stream_subscription_notifications(
508                &mut attachment,
509                format,
510                subscription_id,
511                cancel,
512            );
513        }
514        Ok(result)
515    }
516
517    fn stream_unsubscribe(&self, params: &Value) -> Result<Value, RpcError> {
518        let subscription_id = required_string(params, "subscriptionId")?;
519        let subscription = self
520            .subscriptions
521            .lock()
522            .map_err(|error| {
523                RpcError::new(
524                    SERVER_ERROR,
525                    format!("subscription registry poisoned: {error}"),
526                )
527            })?
528            .remove(&subscription_id);
529        let unsubscribed = subscription.is_some();
530        if let Some(cancel) = subscription {
531            cancel.store(true, Ordering::SeqCst);
532        }
533        Ok(json!({
534            "subscriptionId": subscription_id,
535            "unsubscribed": unsubscribed,
536        }))
537    }
538
539    fn run_status(&self, params: &Value) -> Result<Value, RpcError> {
540        let session_id = required_string(params, "sessionId")?;
541        let run_id = required_string(params, "runId")?;
542        let status = self
543            .coordinator
544            .run_status(&session_id, &run_id)
545            .map_err(RpcError::from)?;
546        Ok(json!({"status": status}))
547    }
548
549    fn run_cancel(&self, params: &Value) -> Result<Value, RpcError> {
550        let run_id = required_string(params, "runId")?;
551        self.coordinator
552            .cancel_run(&run_id)
553            .map_err(RpcError::from)?;
554        Ok(json!({"runId": run_id, "cancelled": true}))
555    }
556
557    fn run_steer(&self, params: &Value) -> Result<Value, RpcError> {
558        let run_id = required_string(params, "runId")?;
559        let text = required_string(params, "text")?;
560        let id = steering_id(params);
561        self.coordinator
562            .steer_run(
563                &run_id,
564                CliSteeringMessage {
565                    id: id.clone(),
566                    text,
567                },
568            )
569            .map_err(RpcError::from)?;
570        Ok(json!({"runId": run_id, "steeringId": id, "queued": true}))
571    }
572
573    fn session_steer(&self, params: &Value) -> Result<Value, RpcError> {
574        let session_id = required_string(params, "sessionId")?;
575        let text = required_string(params, "text")?;
576        let id = steering_id(params);
577        let run_id = self
578            .coordinator
579            .steer_session(
580                &session_id,
581                CliSteeringMessage {
582                    id: id.clone(),
583                    text,
584                },
585            )
586            .map_err(RpcError::from)?;
587        Ok(json!({
588            "sessionId": session_id,
589            "runId": run_id,
590            "steeringId": id,
591            "queued": true,
592        }))
593    }
594
595    fn run_await(&self, params: &Value) -> Result<Value, RpcError> {
596        let session_id = required_string(params, "sessionId")?;
597        let run_id = required_string(params, "runId")?;
598        let timeout = params
599            .get("timeoutMs")
600            .and_then(Value::as_u64)
601            .map(Duration::from_millis);
602        let attachment = self
603            .coordinator
604            .attach_run(&session_id, &run_id, None)
605            .map_err(RpcError::from)?;
606        let Some(receiver) = attachment.subscription else {
607            return self.run_status(params);
608        };
609        loop {
610            let event = match timeout {
611                Some(timeout) => receiver
612                    .recv_timeout(timeout)
613                    .map_err(|error| RpcError::new(SERVER_ERROR, error.to_string()))?,
614                None => receiver
615                    .recv()
616                    .map_err(|error| RpcError::new(SERVER_ERROR, error.to_string()))?,
617            };
618            if let RunStreamEvent::Status(status) = event {
619                if status_is_terminal(&status.status) {
620                    return Ok(json!({"status": status}));
621                }
622            }
623        }
624    }
625
626    fn spawn_run_notifications(&self, started: StartedRun, format: StreamPayloadFormat) {
627        let output_sender = self.output_sender.clone();
628        let environment_manager = self.environment_manager.clone();
629        let send_notifications = self.notifications.supports_live_notifications();
630        thread::spawn(move || {
631            let session_id = started.session_id.clone();
632            let run_id = started.run_id.clone();
633            if send_notifications {
634                let _ = output_sender.send(notification(
635                    "run.started",
636                    &json!({
637                        "sessionId": session_id,
638                        "runId": run_id,
639                        "status": "running",
640                    }),
641                ));
642            }
643            forward_started_run_events(
644                &output_sender,
645                started.events,
646                format,
647                send_notifications,
648                &environment_manager,
649                &run_id,
650            );
651        });
652    }
653
654    fn validate_environment_attach_scope(&self, params: &Value) -> Result<(), RpcError> {
655        if self.notifications.supports_live_notifications() {
656            return Ok(());
657        }
658        let scope_kind = params
659            .get("scope")
660            .and_then(|scope| scope.get("kind"))
661            .and_then(Value::as_str)
662            .unwrap_or("connection");
663        if scope_kind == "connection" {
664            return Err(RpcError::new(
665                UNSUPPORTED_FEATURE,
666                "connection-scoped environment attachments are not supported by replay-only transports; use session scope",
667            ));
668        }
669        Ok(())
670    }
671
672    fn spawn_attachment_notifications(
673        &self,
674        attachment: &mut RunAttachment,
675        format: StreamPayloadFormat,
676    ) {
677        if !self.notifications.supports_live_notifications() {
678            return;
679        }
680        let Some(subscription) = attachment.subscription.take() else {
681            return;
682        };
683        let output_sender = self.output_sender.clone();
684        thread::spawn(move || forward_run_events(&output_sender, subscription, format));
685    }
686
687    fn spawn_stream_subscription_notifications(
688        &self,
689        attachment: &mut RunAttachment,
690        format: StreamPayloadFormat,
691        subscription_id: String,
692        cancel: Arc<AtomicBool>,
693    ) {
694        if !self.notifications.supports_live_notifications() {
695            return;
696        }
697        let Some(subscription) = attachment.subscription.take() else {
698            return;
699        };
700        let output_sender = self.output_sender.clone();
701        let subscriptions = Arc::clone(&self.subscriptions);
702        thread::spawn(move || {
703            forward_subscription_events(
704                &output_sender,
705                &subscription,
706                format,
707                &subscription_id,
708                &cancel,
709            );
710            if let Ok(mut subscriptions) = subscriptions.lock() {
711                subscriptions.remove(&subscription_id);
712            }
713        });
714    }
715}
716
717fn build_rpc_runtime() -> Arc<Runtime> {
718    let runtime = tokio::runtime::Builder::new_multi_thread()
719        .enable_all()
720        .thread_name("starweaver-rpc")
721        .build();
722    match runtime {
723        Ok(runtime) => Arc::new(runtime),
724        Err(error) => panic!("failed to build RPC async runtime: {error}"),
725    }
726}
727
728fn run_command_from_params(
729    config: &CliConfig,
730    params: &Value,
731    output: OutputMode,
732) -> Result<RunCommand, RpcError> {
733    let prompt = required_string(params, "prompt")?;
734    let environment_attachments = environment_attachment_refs(params)?;
735    validate_environment_attachments_for_run(&environment_attachments)?;
736    validate_run_session_selectors(params)?;
737    let client = params.get("client").and_then(Value::as_str);
738    let client_profile = client
739        .map(|client| client_state::read_selected_profile(config, client).map_err(RpcError::from))
740        .transpose()?
741        .flatten();
742    let profile = params
743        .get("profile")
744        .or_else(|| params.get("modelProfile"))
745        .and_then(Value::as_str)
746        .map(ToString::to_string)
747        .or(client_profile)
748        .unwrap_or_else(|| config.default_profile.clone());
749    ensure_profile(config, &profile)?;
750    Ok(RunCommand {
751        prompt: Some(prompt),
752        prompt_parts: Vec::new(),
753        session: params
754            .get("sessionId")
755            .and_then(Value::as_str)
756            .map(ToString::to_string),
757        continue_session: params
758            .get("continueLatest")
759            .and_then(Value::as_bool)
760            .unwrap_or(false),
761        new_session: params
762            .get("newSession")
763            .and_then(Value::as_bool)
764            .unwrap_or(false),
765        run: params
766            .get("restoreFromRunId")
767            .or_else(|| params.get("runId"))
768            .and_then(Value::as_str)
769            .map(ToString::to_string),
770        branch_from: params
771            .get("branchFromRunId")
772            .and_then(Value::as_str)
773            .map(ToString::to_string),
774        profile: Some(profile),
775        worker: None,
776        worker_label: None,
777        worktree: None,
778        worktree_name: None,
779        branch: None,
780        output: Some(output),
781        hitl: params
782            .get("hitl")
783            .and_then(Value::as_str)
784            .and_then(parse_hitl),
785        goal: None,
786        session_affinity_id: None,
787        environment_attachments,
788    })
789}
790
791fn validate_run_session_selectors(params: &Value) -> Result<(), RpcError> {
792    let session_id = params.get("sessionId").and_then(Value::as_str).is_some();
793    let continue_latest = params
794        .get("continueLatest")
795        .and_then(Value::as_bool)
796        .unwrap_or(false);
797    let new_session = params
798        .get("newSession")
799        .and_then(Value::as_bool)
800        .unwrap_or(false);
801    let selected =
802        usize::from(session_id) + usize::from(continue_latest) + usize::from(new_session);
803    if selected > 1 {
804        return Err(RpcError::new(
805            INVALID_PARAMS,
806            "run session selectors are mutually exclusive: use only one of sessionId, continueLatest, or newSession",
807        ));
808    }
809    Ok(())
810}
811
812fn validate_environment_attachments_for_run(
813    attachments: &[starweaver_rpc_core::EnvironmentAttachmentRef],
814) -> Result<(), RpcError> {
815    for attachment in attachments {
816        match attachment.kind.as_str() {
817            "local" => {}
818            "envd" => {
819                let Some(endpoint) = attachment.requested_endpoint_ref() else {
820                    return Err(RpcError::new(
821                        INVALID_PARAMS,
822                        "envd environment attachment requires endpointRef",
823                    ));
824                };
825                if !endpoint.starts_with("http://") {
826                    return Err(RpcError::new(
827                        UNSUPPORTED_FEATURE,
828                        "envd environment attachment currently supports http:// endpoint refs",
829                    ));
830                }
831            }
832            other => {
833                return Err(RpcError::new(
834                    UNSUPPORTED_FEATURE,
835                    format!("unsupported environment attachment kind: {other}"),
836                ));
837            }
838        }
839    }
840    Ok(())
841}
842
843fn steering_id(params: &Value) -> String {
844    params
845        .get("steeringId")
846        .or_else(|| params.get("id"))
847        .and_then(Value::as_str)
848        .filter(|value| !value.trim().is_empty())
849        .map_or_else(
850            || format!("steer_{}", chrono::Utc::now().timestamp_micros()),
851            ToString::to_string,
852        )
853}
854
855fn subscription_id(params: &Value) -> String {
856    params
857        .get("subscriptionId")
858        .or_else(|| params.get("id"))
859        .and_then(Value::as_str)
860        .filter(|value| !value.trim().is_empty())
861        .map_or_else(
862            || format!("stream_{}", chrono::Utc::now().timestamp_micros()),
863            ToString::to_string,
864        )
865}
866
867fn prompt_active_environment_run_id() -> String {
868    format!("run_prompt_{}", Uuid::new_v4().simple())
869}
870
871fn pending_active_environment_run_id() -> String {
872    format!("run_start_pending_{}", Uuid::new_v4().simple())
873}
874
875fn forward_run_events(
876    output_sender: &mpsc::Sender<Value>,
877    receiver: mpsc::Receiver<RunStreamEvent>,
878    format: StreamPayloadFormat,
879) {
880    for event in receiver {
881        let Some(frame) = run_event_notification(event, format) else {
882            continue;
883        };
884        if output_sender.send(frame).is_err() {
885            break;
886        }
887    }
888}
889
890fn forward_started_run_events(
891    output_sender: &mpsc::Sender<Value>,
892    receiver: mpsc::Receiver<RunStreamEvent>,
893    format: StreamPayloadFormat,
894    send_notifications: bool,
895    environment_manager: &EnvironmentAttachmentManager,
896    run_id: &str,
897) {
898    for event in receiver {
899        let terminal = matches!(
900            &event,
901            RunStreamEvent::Status(status) if status_is_terminal(&status.status)
902        );
903        if send_notifications {
904            if let Some(frame) = run_event_notification(event, format) {
905                let _ = output_sender.send(frame);
906            }
907        }
908        if terminal {
909            let _ = environment_manager.mark_run_finished(run_id);
910            return;
911        }
912    }
913    let _ = environment_manager.mark_run_finished(run_id);
914}
915
916fn run_event_notification(event: RunStreamEvent, format: StreamPayloadFormat) -> Option<Value> {
917    match event {
918        RunStreamEvent::Output(output) => {
919            let output = output_item(&output, format)?;
920            Some(notification("run.output", &json!(output)))
921        }
922        RunStreamEvent::Status(status) => Some(notification("run.status", &json!(status))),
923        RunStreamEvent::Raw(_) => None,
924    }
925}
926
927fn forward_subscription_events(
928    output_sender: &mpsc::Sender<Value>,
929    receiver: &mpsc::Receiver<RunStreamEvent>,
930    format: StreamPayloadFormat,
931    subscription_id: &str,
932    cancel: &AtomicBool,
933) {
934    while !cancel.load(Ordering::SeqCst) {
935        let event = match receiver.recv_timeout(Duration::from_millis(100)) {
936            Ok(event) => event,
937            Err(mpsc::RecvTimeoutError::Timeout) => continue,
938            Err(mpsc::RecvTimeoutError::Disconnected) => break,
939        };
940        let terminal = matches!(
941            &event,
942            RunStreamEvent::Status(status) if status_is_terminal(&status.status)
943        );
944        let frame = match event {
945            RunStreamEvent::Output(output) => {
946                let Some(output) = output_item(&output, format) else {
947                    continue;
948                };
949                let mut params = json!(output);
950                insert_subscription_id(&mut params, subscription_id);
951                notification("stream.output", &params)
952            }
953            RunStreamEvent::Status(status) => {
954                let mut params = json!(status);
955                insert_subscription_id(&mut params, subscription_id);
956                notification("stream.status", &params)
957            }
958            RunStreamEvent::Raw(_) => continue,
959        };
960        if output_sender.send(frame).is_err() || terminal {
961            break;
962        }
963    }
964}
965
966fn insert_subscription_id(value: &mut Value, subscription_id: &str) {
967    if let Some(object) = value.as_object_mut() {
968        object.insert(
969            "subscriptionId".to_string(),
970            Value::String(subscription_id.to_string()),
971        );
972    }
973}
974
975fn merge_object(target: &mut Value, source: &Value) {
976    let Some(target) = target.as_object_mut() else {
977        return;
978    };
979    let Some(source) = source.as_object() else {
980        return;
981    };
982    for (key, value) in source {
983        target.insert(key.clone(), value.clone());
984    }
985}
986
987fn status_is_terminal(status: &str) -> bool {
988    matches!(status, "completed" | "failed" | "cancelled")
989}
990
991fn initialize_result(config: &CliConfig, notifications: RpcNotificationMode) -> Value {
992    let live_notifications = notifications.supports_live_notifications();
993    json!({
994        "protocolVersion": PROTOCOL_VERSION,
995        "serverInfo": {"name": "starweaver-cli", "version": env!("CARGO_PKG_VERSION")},
996        "capabilities": {
997            "sessions": true,
998            "runs": true,
999            "management": true,
1000            "profiles": true,
1001            "clientModelSelection": true,
1002            "blockingRunStart": false,
1003            "blockingRunPrompt": true,
1004            "nonBlockingRunStart": true,
1005            "liveDisplay": live_notifications,
1006            "streamReplay": true,
1007            "streamSubscribe": live_notifications,
1008            "cancel": true,
1009            "steering": true,
1010            "attach": true,
1011            "environmentAttachments": true,
1012            "defaultStreamPayload": "agui",
1013            "approvals": true,
1014            "deferred": true
1015        },
1016        "config": {
1017            "globalDir": config.global_dir,
1018            "projectDir": config.project_dir,
1019            "tuiStateDir": config.tui_state_dir,
1020            "desktopStateDir": config.desktop_state_dir,
1021            "defaultProfile": config.default_profile,
1022        }
1023    })
1024}
1025
1026fn config_get(config: &CliConfig, params: &Value) -> Result<Value, RpcError> {
1027    if let Some(key) = params.get("key").and_then(Value::as_str) {
1028        let value = get_config_value(config, key)
1029            .map_err(RpcError::from)?
1030            .trim_end_matches('\n')
1031            .to_string();
1032        return Ok(json!({"values": {key: value}}));
1033    }
1034    let Some(keys) = params.get("keys").and_then(Value::as_array) else {
1035        return Err(RpcError::new(
1036            INVALID_PARAMS,
1037            "config.get requires key or keys",
1038        ));
1039    };
1040    let mut values = serde_json::Map::new();
1041    for key in keys {
1042        let Some(key) = key.as_str() else {
1043            return Err(RpcError::new(INVALID_PARAMS, "keys must be strings"));
1044        };
1045        let value = get_config_value(config, key)
1046            .map_err(RpcError::from)?
1047            .trim_end_matches('\n')
1048            .to_string();
1049        values.insert(key.to_string(), Value::String(value));
1050    }
1051    Ok(json!({"values": values}))
1052}
1053
1054fn selected_profile_result(config: &CliConfig, client: Option<&str>) -> Result<Value, RpcError> {
1055    let selected = client
1056        .map(|client| client_state::read_selected_profile(config, client).map_err(RpcError::from))
1057        .transpose()?
1058        .flatten()
1059        .unwrap_or_else(|| config.default_profile.clone());
1060    Ok(json!({
1061        "client": client,
1062        "selectedProfile": selected,
1063        "modelId": model_id_for_profile(config, &selected),
1064    }))
1065}
1066
1067fn selected_model_profile_result(
1068    config: &CliConfig,
1069    client: Option<&str>,
1070) -> Result<Value, RpcError> {
1071    let configured_profiles = list_config_model_profiles(config);
1072    let persisted = client
1073        .map(|client| client_state::read_selected_profile(config, client).map_err(RpcError::from))
1074        .transpose()?
1075        .flatten();
1076    let selected = persisted
1077        .filter(|profile| {
1078            configured_profiles
1079                .iter()
1080                .any(|summary| summary.name == *profile)
1081        })
1082        .or_else(|| {
1083            configured_profiles
1084                .iter()
1085                .find(|summary| summary.name == config.default_profile)
1086                .map(|summary| summary.name.clone())
1087        })
1088        .or_else(|| {
1089            configured_profiles
1090                .first()
1091                .map(|summary| summary.name.clone())
1092        });
1093    let model_id = selected
1094        .as_deref()
1095        .and_then(|selected| {
1096            configured_profiles
1097                .iter()
1098                .find(|summary| summary.name == selected)
1099        })
1100        .map(|summary| summary.model_id.clone());
1101    Ok(json!({
1102        "client": client,
1103        "selectedProfile": selected,
1104        "modelId": model_id,
1105    }))
1106}
1107
1108fn ensure_profile(config: &CliConfig, profile: &str) -> Result<(), RpcError> {
1109    if list_profiles(config)
1110        .iter()
1111        .any(|summary| summary.name == profile)
1112    {
1113        Ok(())
1114    } else {
1115        Err(RpcError::new(
1116            INVALID_PARAMS,
1117            format!("unknown profile: {profile}"),
1118        ))
1119    }
1120}
1121
1122fn ensure_client_model_profile(config: &CliConfig, profile: &str) -> Result<(), RpcError> {
1123    if list_config_model_profiles(config)
1124        .iter()
1125        .any(|summary| summary.name == profile)
1126    {
1127        Ok(())
1128    } else {
1129        Err(RpcError::new(
1130            INVALID_PARAMS,
1131            format!("unknown model profile: {profile}"),
1132        ))
1133    }
1134}
1135
1136fn model_id_for_profile(config: &CliConfig, profile: &str) -> Option<String> {
1137    list_profiles(config)
1138        .into_iter()
1139        .find(|summary| summary.name == profile)
1140        .map(|summary| summary.model_id)
1141}
1142
1143fn parse_hitl(value: &str) -> Option<HitlPolicy> {
1144    match value {
1145        "deny" => Some(HitlPolicy::Deny),
1146        "defer" => Some(HitlPolicy::Defer),
1147        "fail" => Some(HitlPolicy::Fail),
1148        "prompt" => Some(HitlPolicy::Prompt),
1149        _ => None,
1150    }
1151}
1152
1153fn required_string(params: &Value, key: &str) -> Result<String, RpcError> {
1154    params
1155        .get(key)
1156        .and_then(Value::as_str)
1157        .filter(|value| !value.trim().is_empty())
1158        .map(ToString::to_string)
1159        .ok_or_else(|| RpcError::new(INVALID_PARAMS, format!("missing string param: {key}")))
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    #![allow(clippy::unwrap_used)]
1165
1166    use std::{
1167        io::{Read as _, Write as _},
1168        net::{TcpListener, TcpStream},
1169        sync::{
1170            atomic::{AtomicBool, Ordering},
1171            Arc,
1172        },
1173    };
1174
1175    use serde_json::json;
1176
1177    use super::*;
1178    use crate::{args, ConfigResolver};
1179
1180    fn test_config(root: &std::path::Path) -> CliConfig {
1181        let cli = args::parse(["starweaver-cli".to_string(), "rpc".to_string()]).unwrap();
1182        ConfigResolver::for_tests(root).resolve(&cli).unwrap()
1183    }
1184
1185    #[allow(clippy::needless_pass_by_value)]
1186    fn request(config: &CliConfig, id: u64, method: &str, params: Value) -> Value {
1187        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1188        let server = RpcService::new(config.clone(), output_sender);
1189        request_with_server(&server, id, method, params)
1190    }
1191
1192    #[allow(clippy::needless_pass_by_value)]
1193    fn request_with_server(server: &RpcService, id: u64, method: &str, params: Value) -> Value {
1194        let line = json!({
1195            "jsonrpc": "2.0",
1196            "id": id,
1197            "method": method,
1198            "params": params,
1199        })
1200        .to_string();
1201        let (response, shutdown) = server.handle_line(&line);
1202        assert!(!shutdown || method == "shutdown");
1203        let response = response.unwrap();
1204        assert_eq!(response["jsonrpc"], "2.0");
1205        assert_eq!(response["id"], id);
1206        assert!(
1207            response.get("error").is_none(),
1208            "unexpected RPC error: {response}"
1209        );
1210        response["result"].clone()
1211    }
1212
1213    #[allow(clippy::needless_pass_by_value)]
1214    fn request_error_with_server(
1215        server: &RpcService,
1216        id: u64,
1217        method: &str,
1218        params: Value,
1219    ) -> Value {
1220        let line = json!({
1221            "jsonrpc": "2.0",
1222            "id": id,
1223            "method": method,
1224            "params": params,
1225        })
1226        .to_string();
1227        let (response, shutdown) = server.handle_line(&line);
1228        assert!(!shutdown);
1229        let response = response.unwrap();
1230        assert_eq!(response["jsonrpc"], "2.0");
1231        assert_eq!(response["id"], id);
1232        response["error"].clone()
1233    }
1234
1235    fn http_post(config: &CliConfig, body: &Value) -> (String, Arc<AtomicBool>) {
1236        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1237        let address = listener.local_addr().unwrap();
1238        let service = Arc::new(RpcService::replay_only(
1239            config.clone(),
1240            transport::closed_notification_sender(),
1241        ));
1242        let shutdown = Arc::new(AtomicBool::new(false));
1243        let server_shutdown = Arc::clone(&shutdown);
1244        let handle = thread::spawn(move || {
1245            let (stream, _address) = listener.accept().unwrap();
1246            transport::handle_http_connection(stream, &service, &server_shutdown).unwrap();
1247        });
1248        let body = body.to_string();
1249        let mut client = TcpStream::connect(address).unwrap();
1250        write!(
1251            client,
1252            "POST /rpc HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1253            body.len()
1254        )
1255        .unwrap();
1256        let mut response = String::new();
1257        client.read_to_string(&mut response).unwrap();
1258        handle.join().unwrap();
1259        (response, shutdown)
1260    }
1261
1262    fn http_body(response: &str) -> Value {
1263        let (headers, body) = response.split_once("\r\n\r\n").unwrap();
1264        assert!(headers.starts_with("HTTP/1.1 200 OK"), "{headers}");
1265        serde_json::from_str(body).unwrap()
1266    }
1267
1268    #[test]
1269    fn initialize_capabilities_follow_notification_transport() {
1270        let temp = tempfile::tempdir().unwrap();
1271        let config = test_config(temp.path());
1272        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1273        let live = RpcService::new(config.clone(), output_sender);
1274        let initialized = request_with_server(&live, 1, "initialize", json!({}));
1275        assert_eq!(initialized["capabilities"]["liveDisplay"], true);
1276        assert_eq!(initialized["capabilities"]["streamSubscribe"], true);
1277
1278        let replay_only = RpcService::replay_only(config, transport::closed_notification_sender());
1279        let initialized = request_with_server(&replay_only, 2, "initialize", json!({}));
1280        assert_eq!(initialized["capabilities"]["liveDisplay"], false);
1281        assert_eq!(initialized["capabilities"]["streamSubscribe"], false);
1282        assert_eq!(initialized["capabilities"]["streamReplay"], true);
1283    }
1284
1285    #[test]
1286    fn rpc_command_parses_http_transport() {
1287        let cli = args::parse([
1288            "starweaver-cli".to_string(),
1289            "rpc".to_string(),
1290            "http".to_string(),
1291            "--host".to_string(),
1292            "127.0.0.1".to_string(),
1293            "--port".to_string(),
1294            "0".to_string(),
1295        ])
1296        .unwrap();
1297        let Some(crate::CliCommand::Rpc(command)) = cli.command else {
1298            panic!("expected rpc command");
1299        };
1300        assert_eq!(command.transport, crate::args::RpcTransport::Http);
1301        assert_eq!(command.host, "127.0.0.1");
1302        assert_eq!(command.port, 0);
1303    }
1304
1305    #[test]
1306    fn initialize_and_model_selection_use_client_state_dirs() {
1307        let temp = tempfile::tempdir().unwrap();
1308        let global = temp.path().join("global");
1309        std::fs::create_dir_all(&global).unwrap();
1310        std::fs::write(
1311            global.join("config.toml"),
1312            r#"
1313[general]
1314model = "test:default"
1315
1316[model_profiles.coding]
1317label = "Coding"
1318model = "test:coding"
1319"#,
1320        )
1321        .unwrap();
1322        let config = test_config(temp.path());
1323
1324        let initialized = request(
1325            &config,
1326            1,
1327            "initialize",
1328            json!({"clientInfo":{"name":"tui"}}),
1329        );
1330        assert_eq!(initialized["protocolVersion"], PROTOCOL_VERSION);
1331        assert_eq!(initialized["capabilities"]["clientModelSelection"], true);
1332        assert_eq!(initialized["config"]["globalDir"], json!(config.global_dir));
1333        assert_eq!(
1334            initialized["config"]["tuiStateDir"],
1335            json!(config.tui_state_dir)
1336        );
1337        assert_eq!(
1338            initialized["config"]["desktopStateDir"],
1339            json!(config.desktop_state_dir)
1340        );
1341
1342        let listed = request(&config, 2, "model.list", json!({"client":"tui"}));
1343        let listed_profiles = listed["profiles"].as_array().unwrap();
1344        assert_eq!(listed_profiles.len(), 2);
1345        assert_eq!(listed_profiles[0]["name"], "default_model");
1346        assert_eq!(listed_profiles[0]["model_id"], "test:default");
1347        assert_eq!(listed_profiles[1]["name"], "coding");
1348        assert_eq!(listed_profiles[1]["model_id"], "test:coding");
1349        assert!(!listed_profiles
1350            .iter()
1351            .any(|profile| profile["source"] == "built-in" || profile["model_id"] == "local_echo"));
1352        assert_eq!(listed["current"]["selectedProfile"], config.default_profile);
1353
1354        let selected = request(
1355            &config,
1356            3,
1357            "model.select",
1358            json!({"client":"tui", "profile":"coding"}),
1359        );
1360        assert_eq!(selected["client"], "tui");
1361        assert_eq!(selected["selectedProfile"], "coding");
1362        assert_eq!(selected["modelId"], "test:coding");
1363        assert!(config.tui_state_dir.join("state.json").exists());
1364        assert!(!config.desktop_state_dir.join("state.json").exists());
1365
1366        let current = request(&config, 4, "model.current", json!({"client":"tui"}));
1367        assert_eq!(current["selectedProfile"], "coding");
1368        let desktop_current = request(&config, 5, "model.current", json!({"client":"desktop"}));
1369        assert_eq!(desktop_current["selectedProfile"], "default_model");
1370        assert_eq!(desktop_current["modelId"], "test:default");
1371    }
1372
1373    #[test]
1374    fn client_model_selection_is_empty_without_configured_profiles() {
1375        let temp = tempfile::tempdir().unwrap();
1376        let config = test_config(temp.path());
1377
1378        let listed = request(&config, 1, "model.list", json!({"client":"tui"}));
1379        assert!(listed["profiles"].as_array().unwrap().is_empty());
1380        assert!(listed["current"]["selectedProfile"].is_null());
1381        assert!(listed["current"]["modelId"].is_null());
1382
1383        let line = json!({
1384            "jsonrpc": "2.0",
1385            "id": 2,
1386            "method": "model.select",
1387            "params": {"client":"tui", "profile":"general"},
1388        })
1389        .to_string();
1390        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1391        let server = RpcService::new(config, output_sender);
1392        let (response, shutdown) = server.handle_line(&line);
1393        assert!(!shutdown);
1394        let response = response.unwrap();
1395        assert_eq!(response["id"], 2);
1396        assert_eq!(response["error"]["code"], -32_602);
1397        assert!(response["error"]["message"]
1398            .as_str()
1399            .unwrap()
1400            .contains("unknown model profile: general"));
1401    }
1402
1403    #[test]
1404    fn client_model_selection_only_uses_configured_profiles() {
1405        let temp = tempfile::tempdir().unwrap();
1406        let global = temp.path().join("global");
1407        std::fs::create_dir_all(&global).unwrap();
1408        std::fs::write(
1409            global.join("config.toml"),
1410            r#"
1411[model_profiles.coding]
1412model = "test:coding"
1413"#,
1414        )
1415        .unwrap();
1416        let config = test_config(temp.path());
1417
1418        let listed = request(&config, 1, "model.list", json!({"client":"tui"}));
1419        let listed_profiles = listed["profiles"].as_array().unwrap();
1420        assert_eq!(listed_profiles.len(), 1);
1421        assert_eq!(listed_profiles[0]["name"], "coding");
1422        assert_eq!(listed_profiles[0]["source"], "config");
1423        assert_eq!(listed_profiles[0]["model_id"], "test:coding");
1424        assert!(!listed_profiles
1425            .iter()
1426            .any(|profile| profile["model_id"] == "local_echo"));
1427
1428        let selected = request(
1429            &config,
1430            2,
1431            "model.select",
1432            json!({"client":"tui", "profile":"coding"}),
1433        );
1434        assert_eq!(selected["selectedProfile"], "coding");
1435        assert_eq!(selected["modelId"], "test:coding");
1436
1437        let current = request(&config, 3, "model.current", json!({"client":"tui"}));
1438        assert_eq!(current["selectedProfile"], "coding");
1439        assert_eq!(current["modelId"], "test:coding");
1440    }
1441
1442    #[test]
1443    fn config_get_and_run_prompt_smoke_through_rpc_dispatch() {
1444        let temp = tempfile::tempdir().unwrap();
1445        let config = test_config(temp.path());
1446
1447        let values = request(
1448            &config,
1449            1,
1450            "config.get",
1451            json!({"keys": ["general.default_profile", "storage.database_path"]}),
1452        );
1453        assert_eq!(
1454            values["values"]["general.default_profile"],
1455            config.default_profile
1456        );
1457        assert_eq!(
1458            values["values"]["storage.database_path"],
1459            config.database_path.display().to_string()
1460        );
1461
1462        let run = request(
1463            &config,
1464            2,
1465            "run.prompt",
1466            json!({"prompt":"hello from rpc", "newSession": true, "client":"tui"}),
1467        );
1468        assert!(run["sessionId"].as_str().unwrap().starts_with("session_"));
1469        assert!(run["runId"].as_str().unwrap().starts_with("run_"));
1470        assert_eq!(run["status"], "completed");
1471        assert!(run["latestCursor"]["sequence"].as_u64().is_some());
1472
1473        let replay = request(
1474            &config,
1475            3,
1476            "session.replay",
1477            json!({"sessionId": run["sessionId"].as_str().unwrap()}),
1478        );
1479        assert!(replay["messages"].as_array().unwrap().len() > 1);
1480        assert_eq!(
1481            replay["scope"],
1482            format!("session:{}", run["sessionId"].as_str().unwrap())
1483        );
1484        assert!(replay["events"].as_array().unwrap().len() > 1);
1485        assert!(replay["latestCursor"]["sequence"].as_u64().is_some());
1486
1487        let tail = request(
1488            &config,
1489            4,
1490            "session.replay",
1491            json!({
1492                "sessionId": run["sessionId"].as_str().unwrap(),
1493                "cursor": replay["latestCursor"],
1494            }),
1495        );
1496        assert_eq!(tail["messages"].as_array().unwrap().len(), 0);
1497    }
1498
1499    #[test]
1500    fn stream_methods_cover_replay_subscribe_and_unsubscribe() {
1501        let temp = tempfile::tempdir().unwrap();
1502        let config = test_config(temp.path());
1503        let created = request(&config, 1, "session.create", json!({"title": "stream rpc"}));
1504        let session_id = created["session"]["session_id"].as_str().unwrap();
1505
1506        let replay = request(
1507            &config,
1508            2,
1509            "stream.replay",
1510            json!({"sessionId": session_id}),
1511        );
1512        assert_eq!(replay["sessionId"], session_id);
1513        assert_eq!(replay["scope"], format!("session:{session_id}"));
1514        assert_eq!(replay["events"].as_array().unwrap().len(), 0);
1515
1516        let subscribed = request(
1517            &config,
1518            3,
1519            "stream.subscribe",
1520            json!({
1521                "sessionId": session_id,
1522                "subscriptionId": "sub_stream_test",
1523                "payloadFormat": "display_message"
1524            }),
1525        );
1526        assert_eq!(subscribed["subscriptionId"], "sub_stream_test");
1527        assert_eq!(subscribed["sessionId"], session_id);
1528        assert_eq!(subscribed["active"], false);
1529        assert_eq!(subscribed["payloadFormat"], "display_message");
1530
1531        let unsubscribed = request(
1532            &config,
1533            4,
1534            "stream.unsubscribe",
1535            json!({"subscriptionId": "sub_stream_test"}),
1536        );
1537        assert_eq!(unsubscribed["subscriptionId"], "sub_stream_test");
1538        assert_eq!(unsubscribed["unsubscribed"], false);
1539    }
1540
1541    #[test]
1542    fn stream_subscribe_requires_live_notification_transport() {
1543        let temp = tempfile::tempdir().unwrap();
1544        let config = test_config(temp.path());
1545        let server = RpcService::replay_only(config, transport::closed_notification_sender());
1546        let line = json!({
1547            "jsonrpc": "2.0",
1548            "id": 1,
1549            "method": "stream.subscribe",
1550            "params": {"sessionId": "session_test"},
1551        })
1552        .to_string();
1553        let (response, shutdown) = server.handle_line(&line);
1554        assert!(!shutdown);
1555        let response = response.unwrap();
1556        assert_eq!(response["id"], 1);
1557        assert_eq!(response["error"]["code"], UNSUPPORTED_FEATURE);
1558        assert!(response["error"]["message"]
1559            .as_str()
1560            .unwrap()
1561            .contains("live notification transport"));
1562    }
1563
1564    #[test]
1565    fn replay_only_transport_requires_session_scoped_environment_leases() {
1566        let temp = tempfile::tempdir().unwrap();
1567        let config = test_config(temp.path());
1568        let server = RpcService::replay_only(config, transport::closed_notification_sender());
1569
1570        let connection_error = request_error_with_server(
1571            &server,
1572            1,
1573            "environment.attach",
1574            json!({
1575                "attachment": {"id": "workspace", "kind": "local"},
1576                "readiness": {"policy": "required"}
1577            }),
1578        );
1579        assert_eq!(connection_error["code"], UNSUPPORTED_FEATURE);
1580        assert!(connection_error["message"]
1581            .as_str()
1582            .unwrap()
1583            .contains("session scope"));
1584
1585        let attached = request_with_server(
1586            &server,
1587            2,
1588            "environment.attach",
1589            json!({
1590                "scope": {"kind": "session", "sessionId": "session_http"},
1591                "attachment": {"id": "workspace", "kind": "local"},
1592                "readiness": {"policy": "required"}
1593            }),
1594        );
1595        assert_eq!(attached["attachment"]["scope"]["kind"], "session");
1596        assert_eq!(attached["attachment"]["scope"]["sessionId"], "session_http");
1597    }
1598
1599    #[test]
1600    fn run_start_accepts_multiple_environment_attachments() {
1601        let temp = tempfile::tempdir().unwrap();
1602        let config = test_config(temp.path());
1603        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1604        let server = RpcService::new(config, output_sender);
1605        let line = json!({
1606            "jsonrpc": "2.0",
1607            "id": 1,
1608            "method": "run.start",
1609            "params": {
1610                "prompt": "hello",
1611                "environmentAttachments": [
1612                    {"id": "workspace", "default": true},
1613                    {"id": "tools"}
1614                ]
1615            },
1616        })
1617        .to_string();
1618        let (response, shutdown) = server.handle_line(&line);
1619        assert!(!shutdown);
1620        let response = response.unwrap();
1621        let result = response["result"].as_object().unwrap();
1622        assert_eq!(result["status"], "running");
1623        assert_eq!(
1624            result["environmentAttachments"].as_array().unwrap().len(),
1625            2
1626        );
1627    }
1628
1629    #[test]
1630    fn environment_attachment_methods_manage_local_lease_lifecycle() {
1631        let temp = tempfile::tempdir().unwrap();
1632        let config = test_config(temp.path());
1633        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1634        let server = RpcService::new(config, output_sender);
1635
1636        let attached = request_with_server(
1637            &server,
1638            1,
1639            "environment.attach",
1640            json!({
1641                "scope": {"kind": "connection"},
1642                "attachment": {"id": "workspace", "kind": "local", "mode": "read_only"},
1643                "readiness": {"policy": "required"},
1644                "idempotencyKey": "workspace"
1645            }),
1646        );
1647        let lease_id = attached["attachment"]["attachmentLeaseId"]
1648            .as_str()
1649            .unwrap()
1650            .to_string();
1651        assert!(lease_id.starts_with("envatt_workspace_"));
1652        assert_eq!(attached["attachment"]["status"], "ready");
1653        assert_eq!(attached["attachment"]["readiness"]["transport"], "ready");
1654        assert_eq!(attached["attachment"]["mode"], "read_only");
1655
1656        let repeated = request_with_server(
1657            &server,
1658            2,
1659            "environment.attach",
1660            json!({
1661                "scope": {"kind": "connection"},
1662                "attachment": {"id": "workspace", "kind": "local", "mode": "read_only"},
1663                "readiness": {"policy": "required"},
1664                "idempotencyKey": "workspace"
1665            }),
1666        );
1667        assert_eq!(
1668            repeated["attachment"]["attachmentLeaseId"],
1669            attached["attachment"]["attachmentLeaseId"]
1670        );
1671
1672        let listed = request_with_server(&server, 3, "environment.list", json!({}));
1673        assert_eq!(listed["attachments"].as_array().unwrap().len(), 1);
1674        assert_eq!(listed["attachments"][0]["attachmentLeaseId"], lease_id);
1675
1676        let health = request_with_server(
1677            &server,
1678            4,
1679            "environment.health",
1680            json!({"attachmentLeaseId": lease_id}),
1681        );
1682        assert_eq!(health["status"], "ready");
1683        assert_eq!(health["readiness"]["environment"], "ready");
1684
1685        let detached = request_with_server(
1686            &server,
1687            5,
1688            "environment.detach",
1689            json!({"attachmentLeaseId": lease_id}),
1690        );
1691        assert_eq!(detached["detached"], true);
1692        let listed_detached = request_with_server(
1693            &server,
1694            6,
1695            "environment.list",
1696            json!({"status": "detached"}),
1697        );
1698        assert_eq!(listed_detached["attachments"].as_array().unwrap().len(), 1);
1699    }
1700
1701    #[test]
1702    fn run_start_materializes_environment_attachment_lease_refs() {
1703        let temp = tempfile::tempdir().unwrap();
1704        let config = test_config(temp.path());
1705        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1706        let server = RpcService::new(config, output_sender);
1707
1708        let attached = request_with_server(
1709            &server,
1710            1,
1711            "environment.attach",
1712            json!({
1713                "attachment": {"id": "workspace", "kind": "local", "mode": "read_only"},
1714                "readiness": {"policy": "required"}
1715            }),
1716        );
1717        let lease_id = attached["attachment"]["attachmentLeaseId"]
1718            .as_str()
1719            .unwrap();
1720        let started = request_with_server(
1721            &server,
1722            2,
1723            "run.start",
1724            json!({
1725                "prompt": "hello leased environment",
1726                "environmentAttachments": [
1727                    {"id": "workspace", "attachmentLeaseId": lease_id, "default": true}
1728                ]
1729            }),
1730        );
1731        assert_eq!(started["status"], "running");
1732        assert_eq!(started["environmentAttachments"][0]["id"], "workspace");
1733        assert_eq!(
1734            started["environmentAttachments"][0]["attachmentLeaseId"],
1735            lease_id
1736        );
1737        assert_eq!(started["environmentAttachments"][0]["mode"], "read_only");
1738    }
1739
1740    #[test]
1741    fn run_start_rejects_session_scoped_environment_lease_for_other_session() {
1742        let temp = tempfile::tempdir().unwrap();
1743        let config = test_config(temp.path());
1744        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1745        let server = RpcService::new(config, output_sender);
1746
1747        let attached = request_with_server(
1748            &server,
1749            1,
1750            "environment.attach",
1751            json!({
1752                "scope": {"kind": "session", "sessionId": "session_a"},
1753                "attachment": {"id": "workspace", "kind": "local"},
1754                "readiness": {"policy": "required"}
1755            }),
1756        );
1757        let lease_id = attached["attachment"]["attachmentLeaseId"]
1758            .as_str()
1759            .unwrap();
1760        let error = request_error_with_server(
1761            &server,
1762            2,
1763            "run.start",
1764            json!({
1765                "prompt": "hello wrong session",
1766                "sessionId": "session_b",
1767                "environmentAttachments": [
1768                    {"id": "workspace", "attachmentLeaseId": lease_id, "default": true}
1769                ]
1770            }),
1771        );
1772        assert_eq!(error["code"], INVALID_PARAMS);
1773        assert!(error["message"].as_str().unwrap().contains("session_a"));
1774        assert!(error["message"].as_str().unwrap().contains("session_b"));
1775    }
1776
1777    #[test]
1778    fn run_start_rejects_ambiguous_session_selector_for_session_scoped_lease() {
1779        let temp = tempfile::tempdir().unwrap();
1780        let config = test_config(temp.path());
1781        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1782        let server = RpcService::new(config, output_sender);
1783
1784        let attached = request_with_server(
1785            &server,
1786            1,
1787            "environment.attach",
1788            json!({
1789                "scope": {"kind": "session", "sessionId": "session_a"},
1790                "attachment": {"id": "workspace", "kind": "local"},
1791                "readiness": {"policy": "required"}
1792            }),
1793        );
1794        let lease_id = attached["attachment"]["attachmentLeaseId"]
1795            .as_str()
1796            .unwrap();
1797        let error = request_error_with_server(
1798            &server,
1799            2,
1800            "run.start",
1801            json!({
1802                "prompt": "hello ambiguous session",
1803                "sessionId": "session_a",
1804                "newSession": true,
1805                "environmentAttachments": [
1806                    {"id": "workspace", "attachmentLeaseId": lease_id, "default": true}
1807                ]
1808            }),
1809        );
1810        assert_eq!(error["code"], INVALID_PARAMS);
1811        assert!(error["message"]
1812            .as_str()
1813            .unwrap()
1814            .contains("mutually exclusive"));
1815    }
1816
1817    #[test]
1818    fn run_prompt_materializes_environment_attachment_lease_refs() {
1819        let temp = tempfile::tempdir().unwrap();
1820        let config = test_config(temp.path());
1821        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1822        let server = RpcService::new(config, output_sender);
1823
1824        let attached = request_with_server(
1825            &server,
1826            1,
1827            "environment.attach",
1828            json!({
1829                "attachment": {"id": "workspace", "kind": "local", "mode": "read_only"},
1830                "readiness": {"policy": "required"}
1831            }),
1832        );
1833        let lease_id = attached["attachment"]["attachmentLeaseId"]
1834            .as_str()
1835            .unwrap();
1836        let run = request_with_server(
1837            &server,
1838            2,
1839            "run.prompt",
1840            json!({
1841                "prompt": "hello leased blocking environment",
1842                "newSession": true,
1843                "environmentAttachments": [
1844                    {"id": "workspace", "attachmentLeaseId": lease_id, "default": true}
1845                ]
1846            }),
1847        );
1848        assert_eq!(run["status"], "completed");
1849        assert!(run["sessionId"].as_str().unwrap().starts_with("session_"));
1850        assert!(run["runId"].as_str().unwrap().starts_with("run_"));
1851    }
1852
1853    #[test]
1854    fn envd_attachment_health_reports_unavailable_without_creating_run() {
1855        let temp = tempfile::tempdir().unwrap();
1856        let config = test_config(temp.path());
1857        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1858        let server = RpcService::new(config, output_sender);
1859        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1860        let endpoint = format!("http://{}/rpc", listener.local_addr().unwrap());
1861        drop(listener);
1862
1863        let health = request_with_server(
1864            &server,
1865            1,
1866            "environment.health",
1867            json!({
1868                "attachment": {
1869                    "id": "remote",
1870                    "kind": "envd",
1871                    "endpointRef": endpoint,
1872                    "environmentId": "dataset"
1873                },
1874                "timeoutMs": 100
1875            }),
1876        );
1877        assert_eq!(health["status"], "unavailable");
1878        assert_eq!(health["readiness"]["transport"], "unavailable");
1879    }
1880
1881    #[test]
1882    fn run_start_fails_before_start_when_required_envd_is_unavailable() {
1883        let temp = tempfile::tempdir().unwrap();
1884        let config = test_config(temp.path());
1885        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1886        let server = RpcService::new(config, output_sender);
1887        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1888        let endpoint = format!("http://{}/rpc", listener.local_addr().unwrap());
1889        drop(listener);
1890
1891        let error = request_error_with_server(
1892            &server,
1893            1,
1894            "run.start",
1895            json!({
1896                "prompt": "hello",
1897                "environmentAttachments": [
1898                    {
1899                        "id": "workspace",
1900                        "kind": "envd",
1901                        "endpointRef": endpoint,
1902                        "environmentId": "dataset",
1903                        "default": true
1904                    }
1905                ]
1906            }),
1907        );
1908        assert_eq!(error["code"], starweaver_rpc_core::ENVIRONMENT_UNAVAILABLE);
1909        assert!(error["message"]
1910            .as_str()
1911            .unwrap()
1912            .to_ascii_lowercase()
1913            .contains("connection"));
1914    }
1915
1916    #[test]
1917    fn run_start_reprobes_best_effort_envd_lease_before_starting() {
1918        let temp = tempfile::tempdir().unwrap();
1919        let config = test_config(temp.path());
1920        let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1921        let server = RpcService::new(config, output_sender);
1922        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1923        let endpoint = format!("http://{}/rpc", listener.local_addr().unwrap());
1924        drop(listener);
1925
1926        let attached = request_with_server(
1927            &server,
1928            1,
1929            "environment.attach",
1930            json!({
1931                "attachment": {
1932                    "id": "remote",
1933                    "kind": "envd",
1934                    "endpointRef": endpoint,
1935                    "environmentId": "dataset"
1936                },
1937                "readiness": {"policy": "best_effort", "timeoutMs": 100}
1938            }),
1939        );
1940        assert_eq!(attached["attachment"]["status"], "unavailable");
1941        let lease_id = attached["attachment"]["attachmentLeaseId"]
1942            .as_str()
1943            .unwrap();
1944
1945        let error = request_error_with_server(
1946            &server,
1947            2,
1948            "run.start",
1949            json!({
1950                "prompt": "hello",
1951                "environmentAttachments": [
1952                    {"id": "workspace", "attachmentLeaseId": lease_id, "default": true}
1953                ]
1954            }),
1955        );
1956        assert_eq!(error["code"], starweaver_rpc_core::ENVIRONMENT_UNAVAILABLE);
1957    }
1958
1959    #[test]
1960    fn run_start_streams_agui_payloads_and_session_output_replays() {
1961        let temp = tempfile::tempdir().unwrap();
1962        let config = test_config(temp.path());
1963        let (output_sender, output_receiver) = mpsc::channel::<Value>();
1964        let server = RpcService::new(config, output_sender);
1965
1966        let started = request_with_server(
1967            &server,
1968            1,
1969            "run.start",
1970            json!({"prompt": "hello live rpc", "newSession": true}),
1971        );
1972        let session_id = started["sessionId"].as_str().unwrap().to_string();
1973        let run_id = started["runId"].as_str().unwrap().to_string();
1974        assert_eq!(started["status"], "running");
1975        assert_eq!(started["payloadFormat"], "agui");
1976
1977        let mut saw_agui_output = false;
1978        let mut saw_terminal_status = false;
1979        for _ in 0..100 {
1980            let frame = output_receiver
1981                .recv_timeout(std::time::Duration::from_secs(2))
1982                .unwrap();
1983            match frame["method"].as_str() {
1984                Some("run.output") => {
1985                    assert_eq!(frame["params"]["payloadFormat"], "agui");
1986                    assert!(frame["params"]["payload"]["type"].is_string());
1987                    saw_agui_output = true;
1988                }
1989                Some("run.status") if frame["params"]["status"] == "completed" => {
1990                    saw_terminal_status = true;
1991                    break;
1992                }
1993                _ => {}
1994            }
1995        }
1996        assert!(saw_agui_output);
1997        assert!(saw_terminal_status);
1998
1999        let output = request_with_server(
2000            &server,
2001            2,
2002            "session.output",
2003            json!({"sessionId": session_id, "runId": run_id}),
2004        );
2005        assert_eq!(output["payloadFormat"], "agui");
2006        let events = output["events"].as_array().unwrap();
2007        assert!(!events.is_empty());
2008        assert!(events
2009            .iter()
2010            .any(|event| event["payload"]["type"] == "RUN_FINISHED"));
2011    }
2012
2013    #[test]
2014    fn rpc_run_prompt_expands_configured_slash_command() {
2015        let temp = tempfile::tempdir().unwrap();
2016        let global = temp.path().join("global");
2017        std::fs::create_dir_all(&global).unwrap();
2018        std::fs::write(
2019            global.join("config.toml"),
2020            r#"
2021[general]
2022model = "local_echo"
2023
2024[commands.review]
2025description = "Review changes"
2026aliases = ["rv"]
2027prompt = "Review via RPC."
2028"#,
2029        )
2030        .unwrap();
2031        let config = test_config(temp.path());
2032
2033        let run = request(
2034            &config,
2035            1,
2036            "run.prompt",
2037            json!({
2038                "prompt":"/rv staged diff",
2039                "newSession": true,
2040                "profile":"default_model",
2041            }),
2042        );
2043        assert_eq!(run["status"], "completed");
2044
2045        let store = crate::LocalStore::open(&config).unwrap();
2046        let run_record = store
2047            .load_run(
2048                run["sessionId"].as_str().unwrap(),
2049                run["runId"].as_str().unwrap(),
2050            )
2051            .unwrap();
2052        let value = serde_json::to_value(run_record).unwrap();
2053        assert_eq!(
2054            value["input"][0]["text"],
2055            "Review via RPC.\n\nUser instruction: staged diff"
2056        );
2057        assert_eq!(value["metadata"]["cli.slash_command.name"], "review");
2058        assert_eq!(value["metadata"]["cli.slash_command.invoked"], "rv");
2059    }
2060
2061    #[test]
2062    fn http_transport_dispatches_json_rpc_requests() {
2063        let temp = tempfile::tempdir().unwrap();
2064        let config = test_config(temp.path());
2065
2066        let (response, shutdown) = http_post(
2067            &config,
2068            &json!({
2069                "jsonrpc": "2.0",
2070                "id": 1,
2071                "method": "initialize",
2072                "params": {"clientInfo": {"name": "http-test"}},
2073            }),
2074        );
2075        assert!(!shutdown.load(Ordering::SeqCst));
2076        let body = http_body(&response);
2077        assert_eq!(body["jsonrpc"], "2.0");
2078        assert_eq!(body["id"], 1);
2079        assert_eq!(body["result"]["protocolVersion"], PROTOCOL_VERSION);
2080        assert_eq!(body["result"]["serverInfo"]["name"], "starweaver-cli");
2081    }
2082
2083    #[test]
2084    fn http_transport_shutdown_marks_server_shutdown() {
2085        let temp = tempfile::tempdir().unwrap();
2086        let config = test_config(temp.path());
2087
2088        let (response, shutdown) = http_post(
2089            &config,
2090            &json!({
2091                "jsonrpc": "2.0",
2092                "id": 7,
2093                "method": "shutdown",
2094                "params": {},
2095            }),
2096        );
2097        assert!(shutdown.load(Ordering::SeqCst));
2098        let body = http_body(&response);
2099        assert_eq!(body["id"], 7);
2100        assert_eq!(body["result"]["status"], "shutdown");
2101    }
2102}