1mod 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
50pub 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, ¶ms).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(¶ms.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(¶ms.run_id, &attachment)?;
346 match self
347 .coordinator
348 .active_environment_mount(¶ms, attachment.clone(), ¶ms_digest)
349 {
350 Ok(outcome) => {
351 if !outcome.applied {
352 self.environment_manager
353 .mark_run_unmounted(¶ms.run_id, &attachment)?;
354 }
355 Ok(outcome.result)
356 }
357 Err(error) => {
358 let _ = self
359 .environment_manager
360 .mark_run_unmounted(¶ms.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(¶ms, ¶ms_digest)
378 .await?;
379 if outcome.applied {
380 self.environment_manager
381 .mark_run_unmounted(¶ms.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(¶ms.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", ¶ms)
1027 }
1028 RunStreamEvent::Status(status) => {
1029 let mut params = json!(status);
1030 insert_subscription_id(&mut params, subscription_id);
1031 notification("stream.status", ¶ms)
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}