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