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