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