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