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