1mod transport;
4
5use std::{
6 collections::HashMap,
7 sync::{
8 atomic::{AtomicBool, Ordering},
9 mpsc, Arc, Mutex,
10 },
11 thread,
12 time::Duration,
13};
14
15use serde_json::{json, Value};
16use starweaver_rpc_core::{
17 attachment_result, handle_json_rpc_text, notification, output_item, replay_cursor_from_params,
18 replay_result, stream_payload_format, RpcError, StreamPayloadFormat, INVALID_PARAMS,
19 METHOD_NOT_FOUND, SERVER_ERROR, UNSUPPORTED_FEATURE,
20};
21use starweaver_stream::ReplayScope;
22
23use crate::{
24 args::{HitlPolicy, OutputMode, RpcCommand, RpcTransport, RunCommand},
25 client_state,
26 config::{get_config_value, read_current_session, write_current_session, CliConfig},
27 local_store::{LocalStore, LocalStreamArchive},
28 profiles::{list_config_model_profiles, list_profiles, show_profile},
29 runner::CliSteeringMessage,
30 runtime_coordinator::{CliRuntimeCoordinator, RunAttachment, RunStreamEvent, StartedRun},
31 CliError, CliResult, CliService,
32};
33
34const PROTOCOL_VERSION: &str = "2026-06-08";
35
36impl From<CliError> for RpcError {
37 fn from(error: CliError) -> Self {
38 Self::new(SERVER_ERROR, error.to_string())
39 }
40}
41
42pub fn run(config: &CliConfig, command: &RpcCommand) -> CliResult<()> {
44 match command.transport {
45 RpcTransport::Stdio => transport::run_stdio(config),
46 RpcTransport::Http => transport::run_http(config, &command.host, command.port),
47 }
48}
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51enum RpcNotificationMode {
52 Live,
53 ReplayOnly,
54}
55
56impl RpcNotificationMode {
57 const fn supports_live_notifications(self) -> bool {
58 matches!(self, Self::Live)
59 }
60}
61
62struct RpcService {
63 config: CliConfig,
64 coordinator: CliRuntimeCoordinator,
65 output_sender: mpsc::Sender<Value>,
66 subscriptions: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
67 notifications: RpcNotificationMode,
68}
69
70impl RpcService {
71 fn new(config: CliConfig, output_sender: mpsc::Sender<Value>) -> Self {
72 Self::with_notification_mode(config, output_sender, RpcNotificationMode::Live)
73 }
74
75 fn replay_only(config: CliConfig, output_sender: mpsc::Sender<Value>) -> Self {
76 Self::with_notification_mode(config, output_sender, RpcNotificationMode::ReplayOnly)
77 }
78
79 fn with_notification_mode(
80 config: CliConfig,
81 output_sender: mpsc::Sender<Value>,
82 notifications: RpcNotificationMode,
83 ) -> Self {
84 Self {
85 coordinator: CliRuntimeCoordinator::new(config.clone()),
86 config,
87 output_sender,
88 subscriptions: Arc::new(Mutex::new(HashMap::new())),
89 notifications,
90 }
91 }
92
93 fn handle_line(&self, line: &str) -> (Option<Value>, bool) {
94 self.handle_text(line)
95 }
96
97 fn handle_text(&self, text: &str) -> (Option<Value>, bool) {
98 let outcome = handle_json_rpc_text(text, |method, params| self.dispatch(method, params));
99 (outcome.response, outcome.shutdown)
100 }
101
102 #[allow(clippy::too_many_lines)]
103 fn dispatch(&self, method: &str, params: &Value) -> Result<Value, RpcError> {
104 let config = &self.config;
105 match method {
106 "initialize" => Ok(initialize_result(config, self.notifications)),
107 "shutdown" => Ok(json!({"status": "shutdown"})),
108 "profile.list" => Ok(json!({
109 "profiles": list_profiles(config),
110 "current": selected_profile_result(config, params.get("client").and_then(Value::as_str))?,
111 })),
112 "model.list" => Ok(json!({
113 "profiles": list_config_model_profiles(config),
114 "current": selected_model_profile_result(config, params.get("client").and_then(Value::as_str))?,
115 })),
116 "profile.get" => {
117 let name = required_string(params, "name")?;
118 let yaml = show_profile(config, &name).map_err(RpcError::from)?;
119 Ok(json!({"name": name, "profile": yaml}))
120 }
121 "model.current" => {
122 selected_model_profile_result(config, params.get("client").and_then(Value::as_str))
123 }
124 "model.select" => {
125 let profile = required_string(params, "profile")?;
126 ensure_client_model_profile(config, &profile)?;
127 let client = params
128 .get("client")
129 .and_then(Value::as_str)
130 .unwrap_or("tui");
131 client_state::write_selected_profile(config, client, &profile)
132 .map_err(RpcError::from)?;
133 Ok(json!({
134 "client": client,
135 "selectedProfile": profile,
136 "modelId": model_id_for_profile(config, &profile),
137 }))
138 }
139 "config.get" => config_get(config, params),
140 "diagnostics.get" => Ok(json!({
141 "sdk": starweaver_core::sdk_name(),
142 "version": env!("CARGO_PKG_VERSION"),
143 "globalDir": config.global_dir,
144 "projectDir": config.project_dir,
145 "tuiStateDir": config.tui_state_dir,
146 "desktopStateDir": config.desktop_state_dir,
147 "databasePath": config.database_path,
148 "defaultProfile": config.default_profile,
149 "profiles": list_profiles(config).len(),
150 })),
151 "session.create" => {
152 let profile = params
153 .get("profile")
154 .and_then(Value::as_str)
155 .unwrap_or(&config.default_profile);
156 let title = params
157 .get("title")
158 .and_then(Value::as_str)
159 .map(ToString::to_string);
160 let mut store = LocalStore::open(config).map_err(RpcError::from)?;
161 let session = store
162 .create_session(profile, title)
163 .map_err(RpcError::from)?;
164 Ok(json!({"session": session}))
165 }
166 "session.list" => {
167 let limit = params
168 .get("limit")
169 .and_then(Value::as_u64)
170 .and_then(|value| usize::try_from(value).ok())
171 .unwrap_or(50);
172 let store = LocalStore::open(config).map_err(RpcError::from)?;
173 let sessions = store.list_sessions(limit).map_err(RpcError::from)?;
174 Ok(json!({"sessions": sessions}))
175 }
176 "session.get" => {
177 let session_id = required_string(params, "sessionId")?;
178 let runs_limit = params
179 .get("runs")
180 .and_then(Value::as_u64)
181 .and_then(|value| usize::try_from(value).ok())
182 .unwrap_or(20);
183 let store = LocalStore::open(config).map_err(RpcError::from)?;
184 let session = store.load_session(&session_id).map_err(RpcError::from)?;
185 let runs = store
186 .list_runs(&session_id, runs_limit)
187 .map_err(RpcError::from)?;
188 Ok(json!({"session": session, "runs": runs}))
189 }
190 "session.current.get" => Ok(json!({
191 "sessionId": read_current_session(config).map_err(RpcError::from)?,
192 })),
193 "session.current.set" => {
194 let session_id = required_string(params, "sessionId")?;
195 write_current_session(config, &session_id).map_err(RpcError::from)?;
196 Ok(json!({"sessionId": session_id}))
197 }
198 "session.replay" | "stream.replay" => self.stream_replay(params),
199 "session.delete" => {
200 let session_id = required_string(params, "sessionId")?;
201 let mut store = LocalStore::open(config).map_err(RpcError::from)?;
202 let deleted = store.delete_session(&session_id).map_err(RpcError::from)?;
203 Ok(json!({"sessionId": session_id, "deleted": deleted}))
204 }
205 "session.output" => self.session_output(params),
206 "stream.subscribe" => self.stream_subscribe(params),
207 "stream.unsubscribe" => self.stream_unsubscribe(params),
208 "run.prompt" => run_prompt(config, params),
209 "run.start" => self.run_start(params),
210 "run.attach" => self.run_attach(params),
211 "run.status" => self.run_status(params),
212 "run.await" => self.run_await(params),
213 "run.cancel" => self.run_cancel(params),
214 "run.steer" => self.run_steer(params),
215 "session.steer" => self.session_steer(params),
216 "approval.list" => {
217 let store = LocalStore::open(config).map_err(RpcError::from)?;
218 let records = store
219 .list_approvals(
220 params.get("sessionId").and_then(Value::as_str),
221 params.get("runId").and_then(Value::as_str),
222 )
223 .map_err(RpcError::from)?;
224 Ok(json!({"approvals": records}))
225 }
226 "approval.show" => {
227 let approval_id = required_string(params, "approvalId")?;
228 let store = LocalStore::open(config).map_err(RpcError::from)?;
229 let approval = store.load_approval(&approval_id).map_err(RpcError::from)?;
230 Ok(json!({"approval": approval}))
231 }
232 "approval.decide" => {
233 let approval_id = required_string(params, "approvalId")?;
234 let status = match required_string(params, "status")?.as_str() {
235 "approved" | "approve" => starweaver_session::ApprovalStatus::Approved,
236 "denied" | "rejected" | "reject" => starweaver_session::ApprovalStatus::Denied,
237 other => {
238 return Err(RpcError::new(
239 INVALID_PARAMS,
240 format!("unknown approval status: {other}"),
241 ))
242 }
243 };
244 let reason = params
245 .get("reason")
246 .and_then(Value::as_str)
247 .map(ToString::to_string);
248 let mut store = LocalStore::open(config).map_err(RpcError::from)?;
249 let approval = store
250 .decide_approval(&approval_id, status, reason)
251 .map_err(RpcError::from)?;
252 Ok(json!({"approval": approval}))
253 }
254 "deferred.list" => {
255 let store = LocalStore::open(config).map_err(RpcError::from)?;
256 let records = store
257 .list_deferred_tools(
258 params.get("sessionId").and_then(Value::as_str),
259 params.get("runId").and_then(Value::as_str),
260 )
261 .map_err(RpcError::from)?;
262 Ok(json!({"deferred": records}))
263 }
264 "deferred.show" => {
265 let deferred_id = required_string(params, "deferredId")?;
266 let store = LocalStore::open(config).map_err(RpcError::from)?;
267 let deferred = store
268 .load_deferred_tool(&deferred_id)
269 .map_err(RpcError::from)?;
270 Ok(json!({"deferred": deferred}))
271 }
272 "deferred.complete" => {
273 let deferred_id = required_string(params, "deferredId")?;
274 let result = params.get("result").cloned().unwrap_or(Value::Null);
275 let mut store = LocalStore::open(config).map_err(RpcError::from)?;
276 let deferred = store
277 .complete_deferred_tool(&deferred_id, result)
278 .map_err(RpcError::from)?;
279 Ok(json!({"deferred": deferred}))
280 }
281 "deferred.fail" => {
282 let deferred_id = required_string(params, "deferredId")?;
283 let error = required_string(params, "error")?;
284 let mut store = LocalStore::open(config).map_err(RpcError::from)?;
285 let deferred = store
286 .fail_deferred_tool(&deferred_id, &error)
287 .map_err(RpcError::from)?;
288 Ok(json!({"deferred": deferred}))
289 }
290 other => Err(RpcError::new(
291 METHOD_NOT_FOUND,
292 format!("method not found: {other}"),
293 )),
294 }
295 }
296
297 fn run_start(&self, params: &Value) -> Result<Value, RpcError> {
298 let format = stream_payload_format(params)?;
299 let command = run_command_from_params(&self.config, params, OutputMode::Json)?;
300 let started = self
301 .coordinator
302 .start_run(command, None)
303 .map_err(RpcError::from)?;
304 let session_id = started.session_id.clone();
305 let run_id = started.run_id.clone();
306 self.spawn_run_notifications(started, format);
307 Ok(json!({
308 "sessionId": session_id,
309 "runId": run_id,
310 "status": "running",
311 "payloadFormat": format.as_str(),
312 }))
313 }
314
315 fn run_attach(&self, params: &Value) -> Result<Value, RpcError> {
316 let session_id = required_string(params, "sessionId")?;
317 let run_id = required_string(params, "runId")?;
318 let format = stream_payload_format(params)?;
319 let cursor = replay_cursor_from_params(params, ReplayScope::run(&run_id))?;
320 let mut attachment = self
321 .coordinator
322 .attach_run(&session_id, &run_id, cursor.as_ref())
323 .map_err(RpcError::from)?;
324 let result = attachment_result(
325 &attachment.session_id,
326 attachment.run_id.as_deref(),
327 attachment.active,
328 &attachment.events,
329 format,
330 );
331 self.spawn_attachment_notifications(&mut attachment, format);
332 Ok(result)
333 }
334
335 fn session_output(&self, params: &Value) -> Result<Value, RpcError> {
336 let session_id = required_string(params, "sessionId")?;
337 let run_id = params.get("runId").and_then(Value::as_str);
338 let format = stream_payload_format(params)?;
339 let scope = run_id.map_or_else(|| ReplayScope::session(&session_id), ReplayScope::run);
340 let cursor = replay_cursor_from_params(params, scope)?;
341 let mut attachment = self
342 .coordinator
343 .session_output(&session_id, run_id, cursor.as_ref())
344 .map_err(RpcError::from)?;
345 let result = attachment_result(
346 &attachment.session_id,
347 attachment.run_id.as_deref(),
348 attachment.active,
349 &attachment.events,
350 format,
351 );
352 self.spawn_attachment_notifications(&mut attachment, format);
353 Ok(result)
354 }
355
356 fn stream_replay(&self, params: &Value) -> Result<Value, RpcError> {
357 let session_id = required_string(params, "sessionId")?;
358 let run_id = params.get("runId").and_then(Value::as_str);
359 let scope = run_id.map_or_else(|| ReplayScope::session(&session_id), ReplayScope::run);
360 let cursor = replay_cursor_from_params(params, scope)?;
361 let archive = LocalStreamArchive::new(self.config.clone());
362 let window = archive
363 .replay_display_window(&session_id, run_id, cursor.as_ref())
364 .map_err(RpcError::from)?;
365 Ok(replay_result(
366 &session_id,
367 run_id,
368 &window.scope,
369 &window.events,
370 cursor.as_ref(),
371 window.next_sequence,
372 ))
373 }
374
375 fn stream_subscribe(&self, params: &Value) -> Result<Value, RpcError> {
376 if !self.notifications.supports_live_notifications() {
377 return Err(RpcError::new(
378 UNSUPPORTED_FEATURE,
379 "stream.subscribe requires a live notification transport",
380 ));
381 }
382 let subscription_id = subscription_id(params);
383 let session_id = required_string(params, "sessionId")?;
384 let run_id = params.get("runId").and_then(Value::as_str);
385 let format = stream_payload_format(params)?;
386 let scope = run_id.map_or_else(|| ReplayScope::session(&session_id), ReplayScope::run);
387 let cursor = replay_cursor_from_params(params, scope)?;
388 let mut attachment = if let Some(run_id) = run_id {
389 self.coordinator
390 .attach_run(&session_id, run_id, cursor.as_ref())
391 .map_err(RpcError::from)?
392 } else {
393 self.coordinator
394 .session_output(&session_id, None, cursor.as_ref())
395 .map_err(RpcError::from)?
396 };
397 let mut result = attachment_result(
398 &attachment.session_id,
399 attachment.run_id.as_deref(),
400 attachment.active,
401 &attachment.events,
402 format,
403 );
404 insert_subscription_id(&mut result, &subscription_id);
405 if attachment.subscription.is_some() {
406 let cancel = Arc::new(AtomicBool::new(false));
407 let mut subscriptions = self.subscriptions.lock().map_err(|error| {
408 RpcError::new(
409 SERVER_ERROR,
410 format!("subscription registry poisoned: {error}"),
411 )
412 })?;
413 if subscriptions.contains_key(&subscription_id) {
414 return Err(RpcError::new(
415 SERVER_ERROR,
416 format!("subscription already exists: {subscription_id}"),
417 ));
418 }
419 subscriptions.insert(subscription_id.clone(), Arc::clone(&cancel));
420 drop(subscriptions);
421 self.spawn_stream_subscription_notifications(
422 &mut attachment,
423 format,
424 subscription_id,
425 cancel,
426 );
427 }
428 Ok(result)
429 }
430
431 fn stream_unsubscribe(&self, params: &Value) -> Result<Value, RpcError> {
432 let subscription_id = required_string(params, "subscriptionId")?;
433 let subscription = self
434 .subscriptions
435 .lock()
436 .map_err(|error| {
437 RpcError::new(
438 SERVER_ERROR,
439 format!("subscription registry poisoned: {error}"),
440 )
441 })?
442 .remove(&subscription_id);
443 let unsubscribed = subscription.is_some();
444 if let Some(cancel) = subscription {
445 cancel.store(true, Ordering::SeqCst);
446 }
447 Ok(json!({
448 "subscriptionId": subscription_id,
449 "unsubscribed": unsubscribed,
450 }))
451 }
452
453 fn run_status(&self, params: &Value) -> Result<Value, RpcError> {
454 let session_id = required_string(params, "sessionId")?;
455 let run_id = required_string(params, "runId")?;
456 let status = self
457 .coordinator
458 .run_status(&session_id, &run_id)
459 .map_err(RpcError::from)?;
460 Ok(json!({"status": status}))
461 }
462
463 fn run_cancel(&self, params: &Value) -> Result<Value, RpcError> {
464 let run_id = required_string(params, "runId")?;
465 self.coordinator
466 .cancel_run(&run_id)
467 .map_err(RpcError::from)?;
468 Ok(json!({"runId": run_id, "cancelled": true}))
469 }
470
471 fn run_steer(&self, params: &Value) -> Result<Value, RpcError> {
472 let run_id = required_string(params, "runId")?;
473 let text = required_string(params, "text")?;
474 let id = steering_id(params);
475 self.coordinator
476 .steer_run(
477 &run_id,
478 CliSteeringMessage {
479 id: id.clone(),
480 text,
481 },
482 )
483 .map_err(RpcError::from)?;
484 Ok(json!({"runId": run_id, "steeringId": id, "queued": true}))
485 }
486
487 fn session_steer(&self, params: &Value) -> Result<Value, RpcError> {
488 let session_id = required_string(params, "sessionId")?;
489 let text = required_string(params, "text")?;
490 let id = steering_id(params);
491 let run_id = self
492 .coordinator
493 .steer_session(
494 &session_id,
495 CliSteeringMessage {
496 id: id.clone(),
497 text,
498 },
499 )
500 .map_err(RpcError::from)?;
501 Ok(json!({
502 "sessionId": session_id,
503 "runId": run_id,
504 "steeringId": id,
505 "queued": true,
506 }))
507 }
508
509 fn run_await(&self, params: &Value) -> Result<Value, RpcError> {
510 let session_id = required_string(params, "sessionId")?;
511 let run_id = required_string(params, "runId")?;
512 let timeout = params
513 .get("timeoutMs")
514 .and_then(Value::as_u64)
515 .map(Duration::from_millis);
516 let attachment = self
517 .coordinator
518 .attach_run(&session_id, &run_id, None)
519 .map_err(RpcError::from)?;
520 let Some(receiver) = attachment.subscription else {
521 return self.run_status(params);
522 };
523 loop {
524 let event = match timeout {
525 Some(timeout) => receiver
526 .recv_timeout(timeout)
527 .map_err(|error| RpcError::new(SERVER_ERROR, error.to_string()))?,
528 None => receiver
529 .recv()
530 .map_err(|error| RpcError::new(SERVER_ERROR, error.to_string()))?,
531 };
532 if let RunStreamEvent::Status(status) = event {
533 if status_is_terminal(&status.status) {
534 return Ok(json!({"status": status}));
535 }
536 }
537 }
538 }
539
540 fn spawn_run_notifications(&self, started: StartedRun, format: StreamPayloadFormat) {
541 if !self.notifications.supports_live_notifications() {
542 return;
543 }
544 let output_sender = self.output_sender.clone();
545 thread::spawn(move || {
546 let session_id = started.session_id.clone();
547 let run_id = started.run_id.clone();
548 let _ = output_sender.send(notification(
549 "run.started",
550 &json!({
551 "sessionId": session_id,
552 "runId": run_id,
553 "status": "running",
554 }),
555 ));
556 forward_run_events(&output_sender, started.events, format);
557 });
558 }
559
560 fn spawn_attachment_notifications(
561 &self,
562 attachment: &mut RunAttachment,
563 format: StreamPayloadFormat,
564 ) {
565 if !self.notifications.supports_live_notifications() {
566 return;
567 }
568 let Some(subscription) = attachment.subscription.take() else {
569 return;
570 };
571 let output_sender = self.output_sender.clone();
572 thread::spawn(move || forward_run_events(&output_sender, subscription, format));
573 }
574
575 fn spawn_stream_subscription_notifications(
576 &self,
577 attachment: &mut RunAttachment,
578 format: StreamPayloadFormat,
579 subscription_id: String,
580 cancel: Arc<AtomicBool>,
581 ) {
582 if !self.notifications.supports_live_notifications() {
583 return;
584 }
585 let Some(subscription) = attachment.subscription.take() else {
586 return;
587 };
588 let output_sender = self.output_sender.clone();
589 let subscriptions = Arc::clone(&self.subscriptions);
590 thread::spawn(move || {
591 forward_subscription_events(
592 &output_sender,
593 &subscription,
594 format,
595 &subscription_id,
596 &cancel,
597 );
598 if let Ok(mut subscriptions) = subscriptions.lock() {
599 subscriptions.remove(&subscription_id);
600 }
601 });
602 }
603}
604
605fn run_command_from_params(
606 config: &CliConfig,
607 params: &Value,
608 output: OutputMode,
609) -> Result<RunCommand, RpcError> {
610 let prompt = required_string(params, "prompt")?;
611 let client = params.get("client").and_then(Value::as_str);
612 let client_profile = client
613 .map(|client| client_state::read_selected_profile(config, client).map_err(RpcError::from))
614 .transpose()?
615 .flatten();
616 let profile = params
617 .get("profile")
618 .or_else(|| params.get("modelProfile"))
619 .and_then(Value::as_str)
620 .map(ToString::to_string)
621 .or(client_profile)
622 .unwrap_or_else(|| config.default_profile.clone());
623 ensure_profile(config, &profile)?;
624 Ok(RunCommand {
625 prompt: Some(prompt),
626 prompt_parts: Vec::new(),
627 session: params
628 .get("sessionId")
629 .and_then(Value::as_str)
630 .map(ToString::to_string),
631 continue_session: params
632 .get("continueLatest")
633 .and_then(Value::as_bool)
634 .unwrap_or(false),
635 new_session: params
636 .get("newSession")
637 .and_then(Value::as_bool)
638 .unwrap_or(false),
639 run: params
640 .get("restoreFromRunId")
641 .or_else(|| params.get("runId"))
642 .and_then(Value::as_str)
643 .map(ToString::to_string),
644 branch_from: params
645 .get("branchFromRunId")
646 .and_then(Value::as_str)
647 .map(ToString::to_string),
648 profile: Some(profile),
649 worker: None,
650 worker_label: None,
651 worktree: None,
652 worktree_name: None,
653 branch: None,
654 output: Some(output),
655 hitl: params
656 .get("hitl")
657 .and_then(Value::as_str)
658 .and_then(parse_hitl),
659 goal: None,
660 session_affinity_id: None,
661 })
662}
663
664fn steering_id(params: &Value) -> String {
665 params
666 .get("steeringId")
667 .or_else(|| params.get("id"))
668 .and_then(Value::as_str)
669 .filter(|value| !value.trim().is_empty())
670 .map_or_else(
671 || format!("steer_{}", chrono::Utc::now().timestamp_micros()),
672 ToString::to_string,
673 )
674}
675
676fn subscription_id(params: &Value) -> String {
677 params
678 .get("subscriptionId")
679 .or_else(|| params.get("id"))
680 .and_then(Value::as_str)
681 .filter(|value| !value.trim().is_empty())
682 .map_or_else(
683 || format!("stream_{}", chrono::Utc::now().timestamp_micros()),
684 ToString::to_string,
685 )
686}
687
688fn forward_run_events(
689 output_sender: &mpsc::Sender<Value>,
690 receiver: mpsc::Receiver<RunStreamEvent>,
691 format: StreamPayloadFormat,
692) {
693 for event in receiver {
694 let frame = match event {
695 RunStreamEvent::Output(output) => {
696 let Some(output) = output_item(&output, format) else {
697 continue;
698 };
699 notification("run.output", &json!(output))
700 }
701 RunStreamEvent::Status(status) => notification("run.status", &json!(status)),
702 RunStreamEvent::Raw(_) => continue,
703 };
704 if output_sender.send(frame).is_err() {
705 break;
706 }
707 }
708}
709
710fn forward_subscription_events(
711 output_sender: &mpsc::Sender<Value>,
712 receiver: &mpsc::Receiver<RunStreamEvent>,
713 format: StreamPayloadFormat,
714 subscription_id: &str,
715 cancel: &AtomicBool,
716) {
717 while !cancel.load(Ordering::SeqCst) {
718 let event = match receiver.recv_timeout(Duration::from_millis(100)) {
719 Ok(event) => event,
720 Err(mpsc::RecvTimeoutError::Timeout) => continue,
721 Err(mpsc::RecvTimeoutError::Disconnected) => break,
722 };
723 let terminal = matches!(
724 &event,
725 RunStreamEvent::Status(status) if status_is_terminal(&status.status)
726 );
727 let frame = match event {
728 RunStreamEvent::Output(output) => {
729 let Some(output) = output_item(&output, format) else {
730 continue;
731 };
732 let mut params = json!(output);
733 insert_subscription_id(&mut params, subscription_id);
734 notification("stream.output", ¶ms)
735 }
736 RunStreamEvent::Status(status) => {
737 let mut params = json!(status);
738 insert_subscription_id(&mut params, subscription_id);
739 notification("stream.status", ¶ms)
740 }
741 RunStreamEvent::Raw(_) => continue,
742 };
743 if output_sender.send(frame).is_err() || terminal {
744 break;
745 }
746 }
747}
748
749fn insert_subscription_id(value: &mut Value, subscription_id: &str) {
750 if let Some(object) = value.as_object_mut() {
751 object.insert(
752 "subscriptionId".to_string(),
753 Value::String(subscription_id.to_string()),
754 );
755 }
756}
757
758fn status_is_terminal(status: &str) -> bool {
759 matches!(status, "completed" | "failed" | "cancelled")
760}
761
762fn initialize_result(config: &CliConfig, notifications: RpcNotificationMode) -> Value {
763 let live_notifications = notifications.supports_live_notifications();
764 json!({
765 "protocolVersion": PROTOCOL_VERSION,
766 "serverInfo": {"name": "starweaver-cli", "version": env!("CARGO_PKG_VERSION")},
767 "capabilities": {
768 "sessions": true,
769 "runs": true,
770 "management": true,
771 "profiles": true,
772 "clientModelSelection": true,
773 "blockingRunStart": false,
774 "blockingRunPrompt": true,
775 "nonBlockingRunStart": true,
776 "liveDisplay": live_notifications,
777 "streamReplay": true,
778 "streamSubscribe": live_notifications,
779 "cancel": true,
780 "steering": true,
781 "attach": true,
782 "defaultStreamPayload": "agui",
783 "approvals": true,
784 "deferred": true
785 },
786 "config": {
787 "globalDir": config.global_dir,
788 "projectDir": config.project_dir,
789 "tuiStateDir": config.tui_state_dir,
790 "desktopStateDir": config.desktop_state_dir,
791 "defaultProfile": config.default_profile,
792 }
793 })
794}
795
796fn run_prompt(config: &CliConfig, params: &Value) -> Result<Value, RpcError> {
797 let command = run_command_from_params(config, params, OutputMode::Json)?;
798 let output = CliService::open(config.clone())
799 .map_err(RpcError::from)?
800 .run_prompt(&command)
801 .map_err(RpcError::from)?;
802 serde_json::from_str(output.trim())
803 .map_err(|error| RpcError::new(SERVER_ERROR, error.to_string()))
804}
805
806fn config_get(config: &CliConfig, params: &Value) -> Result<Value, RpcError> {
807 if let Some(key) = params.get("key").and_then(Value::as_str) {
808 let value = get_config_value(config, key)
809 .map_err(RpcError::from)?
810 .trim_end_matches('\n')
811 .to_string();
812 return Ok(json!({"values": {key: value}}));
813 }
814 let Some(keys) = params.get("keys").and_then(Value::as_array) else {
815 return Err(RpcError::new(
816 INVALID_PARAMS,
817 "config.get requires key or keys",
818 ));
819 };
820 let mut values = serde_json::Map::new();
821 for key in keys {
822 let Some(key) = key.as_str() else {
823 return Err(RpcError::new(INVALID_PARAMS, "keys must be strings"));
824 };
825 let value = get_config_value(config, key)
826 .map_err(RpcError::from)?
827 .trim_end_matches('\n')
828 .to_string();
829 values.insert(key.to_string(), Value::String(value));
830 }
831 Ok(json!({"values": values}))
832}
833
834fn selected_profile_result(config: &CliConfig, client: Option<&str>) -> Result<Value, RpcError> {
835 let selected = client
836 .map(|client| client_state::read_selected_profile(config, client).map_err(RpcError::from))
837 .transpose()?
838 .flatten()
839 .unwrap_or_else(|| config.default_profile.clone());
840 Ok(json!({
841 "client": client,
842 "selectedProfile": selected,
843 "modelId": model_id_for_profile(config, &selected),
844 }))
845}
846
847fn selected_model_profile_result(
848 config: &CliConfig,
849 client: Option<&str>,
850) -> Result<Value, RpcError> {
851 let configured_profiles = list_config_model_profiles(config);
852 let persisted = client
853 .map(|client| client_state::read_selected_profile(config, client).map_err(RpcError::from))
854 .transpose()?
855 .flatten();
856 let selected = persisted
857 .filter(|profile| {
858 configured_profiles
859 .iter()
860 .any(|summary| summary.name == *profile)
861 })
862 .or_else(|| {
863 configured_profiles
864 .iter()
865 .find(|summary| summary.name == config.default_profile)
866 .map(|summary| summary.name.clone())
867 })
868 .or_else(|| {
869 configured_profiles
870 .first()
871 .map(|summary| summary.name.clone())
872 });
873 let model_id = selected
874 .as_deref()
875 .and_then(|selected| {
876 configured_profiles
877 .iter()
878 .find(|summary| summary.name == selected)
879 })
880 .map(|summary| summary.model_id.clone());
881 Ok(json!({
882 "client": client,
883 "selectedProfile": selected,
884 "modelId": model_id,
885 }))
886}
887
888fn ensure_profile(config: &CliConfig, profile: &str) -> Result<(), RpcError> {
889 if list_profiles(config)
890 .iter()
891 .any(|summary| summary.name == profile)
892 {
893 Ok(())
894 } else {
895 Err(RpcError::new(
896 INVALID_PARAMS,
897 format!("unknown profile: {profile}"),
898 ))
899 }
900}
901
902fn ensure_client_model_profile(config: &CliConfig, profile: &str) -> Result<(), RpcError> {
903 if list_config_model_profiles(config)
904 .iter()
905 .any(|summary| summary.name == profile)
906 {
907 Ok(())
908 } else {
909 Err(RpcError::new(
910 INVALID_PARAMS,
911 format!("unknown model profile: {profile}"),
912 ))
913 }
914}
915
916fn model_id_for_profile(config: &CliConfig, profile: &str) -> Option<String> {
917 list_profiles(config)
918 .into_iter()
919 .find(|summary| summary.name == profile)
920 .map(|summary| summary.model_id)
921}
922
923fn parse_hitl(value: &str) -> Option<HitlPolicy> {
924 match value {
925 "deny" => Some(HitlPolicy::Deny),
926 "defer" => Some(HitlPolicy::Defer),
927 "fail" => Some(HitlPolicy::Fail),
928 "prompt" => Some(HitlPolicy::Prompt),
929 _ => None,
930 }
931}
932
933fn required_string(params: &Value, key: &str) -> Result<String, RpcError> {
934 params
935 .get(key)
936 .and_then(Value::as_str)
937 .filter(|value| !value.trim().is_empty())
938 .map(ToString::to_string)
939 .ok_or_else(|| RpcError::new(INVALID_PARAMS, format!("missing string param: {key}")))
940}
941
942#[cfg(test)]
943mod tests {
944 #![allow(clippy::unwrap_used)]
945
946 use std::{
947 io::{Read as _, Write as _},
948 net::{TcpListener, TcpStream},
949 sync::{
950 atomic::{AtomicBool, Ordering},
951 Arc,
952 },
953 };
954
955 use serde_json::json;
956
957 use super::*;
958 use crate::{args, ConfigResolver};
959
960 fn test_config(root: &std::path::Path) -> CliConfig {
961 let cli = args::parse(["starweaver-cli".to_string(), "rpc".to_string()]).unwrap();
962 ConfigResolver::for_tests(root).resolve(&cli).unwrap()
963 }
964
965 #[allow(clippy::needless_pass_by_value)]
966 fn request(config: &CliConfig, id: u64, method: &str, params: Value) -> Value {
967 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
968 let server = RpcService::new(config.clone(), output_sender);
969 request_with_server(&server, id, method, params)
970 }
971
972 #[allow(clippy::needless_pass_by_value)]
973 fn request_with_server(server: &RpcService, id: u64, method: &str, params: Value) -> Value {
974 let line = json!({
975 "jsonrpc": "2.0",
976 "id": id,
977 "method": method,
978 "params": params,
979 })
980 .to_string();
981 let (response, shutdown) = server.handle_line(&line);
982 assert!(!shutdown || method == "shutdown");
983 let response = response.unwrap();
984 assert_eq!(response["jsonrpc"], "2.0");
985 assert_eq!(response["id"], id);
986 assert!(
987 response.get("error").is_none(),
988 "unexpected RPC error: {response}"
989 );
990 response["result"].clone()
991 }
992
993 fn http_post(config: &CliConfig, body: &Value) -> (String, Arc<AtomicBool>) {
994 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
995 let address = listener.local_addr().unwrap();
996 let service = Arc::new(RpcService::replay_only(
997 config.clone(),
998 transport::closed_notification_sender(),
999 ));
1000 let shutdown = Arc::new(AtomicBool::new(false));
1001 let server_shutdown = Arc::clone(&shutdown);
1002 let handle = thread::spawn(move || {
1003 let (stream, _address) = listener.accept().unwrap();
1004 transport::handle_http_connection(stream, &service, &server_shutdown).unwrap();
1005 });
1006 let body = body.to_string();
1007 let mut client = TcpStream::connect(address).unwrap();
1008 write!(
1009 client,
1010 "POST /rpc HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1011 body.len()
1012 )
1013 .unwrap();
1014 let mut response = String::new();
1015 client.read_to_string(&mut response).unwrap();
1016 handle.join().unwrap();
1017 (response, shutdown)
1018 }
1019
1020 fn http_body(response: &str) -> Value {
1021 let (headers, body) = response.split_once("\r\n\r\n").unwrap();
1022 assert!(headers.starts_with("HTTP/1.1 200 OK"), "{headers}");
1023 serde_json::from_str(body).unwrap()
1024 }
1025
1026 #[test]
1027 fn initialize_capabilities_follow_notification_transport() {
1028 let temp = tempfile::tempdir().unwrap();
1029 let config = test_config(temp.path());
1030 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1031 let live = RpcService::new(config.clone(), output_sender);
1032 let initialized = request_with_server(&live, 1, "initialize", json!({}));
1033 assert_eq!(initialized["capabilities"]["liveDisplay"], true);
1034 assert_eq!(initialized["capabilities"]["streamSubscribe"], true);
1035
1036 let replay_only = RpcService::replay_only(config, transport::closed_notification_sender());
1037 let initialized = request_with_server(&replay_only, 2, "initialize", json!({}));
1038 assert_eq!(initialized["capabilities"]["liveDisplay"], false);
1039 assert_eq!(initialized["capabilities"]["streamSubscribe"], false);
1040 assert_eq!(initialized["capabilities"]["streamReplay"], true);
1041 }
1042
1043 #[test]
1044 fn rpc_command_parses_http_transport() {
1045 let cli = args::parse([
1046 "starweaver-cli".to_string(),
1047 "rpc".to_string(),
1048 "http".to_string(),
1049 "--host".to_string(),
1050 "127.0.0.1".to_string(),
1051 "--port".to_string(),
1052 "0".to_string(),
1053 ])
1054 .unwrap();
1055 let Some(crate::CliCommand::Rpc(command)) = cli.command else {
1056 panic!("expected rpc command");
1057 };
1058 assert_eq!(command.transport, crate::args::RpcTransport::Http);
1059 assert_eq!(command.host, "127.0.0.1");
1060 assert_eq!(command.port, 0);
1061 }
1062
1063 #[test]
1064 fn initialize_and_model_selection_use_client_state_dirs() {
1065 let temp = tempfile::tempdir().unwrap();
1066 let global = temp.path().join("global");
1067 std::fs::create_dir_all(&global).unwrap();
1068 std::fs::write(
1069 global.join("config.toml"),
1070 r#"
1071[general]
1072model = "test:default"
1073
1074[model_profiles.coding]
1075label = "Coding"
1076model = "test:coding"
1077"#,
1078 )
1079 .unwrap();
1080 let config = test_config(temp.path());
1081
1082 let initialized = request(
1083 &config,
1084 1,
1085 "initialize",
1086 json!({"clientInfo":{"name":"tui"}}),
1087 );
1088 assert_eq!(initialized["protocolVersion"], PROTOCOL_VERSION);
1089 assert_eq!(initialized["capabilities"]["clientModelSelection"], true);
1090 assert_eq!(initialized["config"]["globalDir"], json!(config.global_dir));
1091 assert_eq!(
1092 initialized["config"]["tuiStateDir"],
1093 json!(config.tui_state_dir)
1094 );
1095 assert_eq!(
1096 initialized["config"]["desktopStateDir"],
1097 json!(config.desktop_state_dir)
1098 );
1099
1100 let listed = request(&config, 2, "model.list", json!({"client":"tui"}));
1101 let listed_profiles = listed["profiles"].as_array().unwrap();
1102 assert_eq!(listed_profiles.len(), 2);
1103 assert_eq!(listed_profiles[0]["name"], "default_model");
1104 assert_eq!(listed_profiles[0]["model_id"], "test:default");
1105 assert_eq!(listed_profiles[1]["name"], "coding");
1106 assert_eq!(listed_profiles[1]["model_id"], "test:coding");
1107 assert!(!listed_profiles
1108 .iter()
1109 .any(|profile| profile["source"] == "built-in" || profile["model_id"] == "local_echo"));
1110 assert_eq!(listed["current"]["selectedProfile"], config.default_profile);
1111
1112 let selected = request(
1113 &config,
1114 3,
1115 "model.select",
1116 json!({"client":"tui", "profile":"coding"}),
1117 );
1118 assert_eq!(selected["client"], "tui");
1119 assert_eq!(selected["selectedProfile"], "coding");
1120 assert_eq!(selected["modelId"], "test:coding");
1121 assert!(config.tui_state_dir.join("state.json").exists());
1122 assert!(!config.desktop_state_dir.join("state.json").exists());
1123
1124 let current = request(&config, 4, "model.current", json!({"client":"tui"}));
1125 assert_eq!(current["selectedProfile"], "coding");
1126 let desktop_current = request(&config, 5, "model.current", json!({"client":"desktop"}));
1127 assert_eq!(desktop_current["selectedProfile"], "default_model");
1128 assert_eq!(desktop_current["modelId"], "test:default");
1129 }
1130
1131 #[test]
1132 fn client_model_selection_is_empty_without_configured_profiles() {
1133 let temp = tempfile::tempdir().unwrap();
1134 let config = test_config(temp.path());
1135
1136 let listed = request(&config, 1, "model.list", json!({"client":"tui"}));
1137 assert!(listed["profiles"].as_array().unwrap().is_empty());
1138 assert!(listed["current"]["selectedProfile"].is_null());
1139 assert!(listed["current"]["modelId"].is_null());
1140
1141 let line = json!({
1142 "jsonrpc": "2.0",
1143 "id": 2,
1144 "method": "model.select",
1145 "params": {"client":"tui", "profile":"general"},
1146 })
1147 .to_string();
1148 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1149 let server = RpcService::new(config, output_sender);
1150 let (response, shutdown) = server.handle_line(&line);
1151 assert!(!shutdown);
1152 let response = response.unwrap();
1153 assert_eq!(response["id"], 2);
1154 assert_eq!(response["error"]["code"], -32_602);
1155 assert!(response["error"]["message"]
1156 .as_str()
1157 .unwrap()
1158 .contains("unknown model profile: general"));
1159 }
1160
1161 #[test]
1162 fn client_model_selection_only_uses_configured_profiles() {
1163 let temp = tempfile::tempdir().unwrap();
1164 let global = temp.path().join("global");
1165 std::fs::create_dir_all(&global).unwrap();
1166 std::fs::write(
1167 global.join("config.toml"),
1168 r#"
1169[model_profiles.coding]
1170model = "test:coding"
1171"#,
1172 )
1173 .unwrap();
1174 let config = test_config(temp.path());
1175
1176 let listed = request(&config, 1, "model.list", json!({"client":"tui"}));
1177 let listed_profiles = listed["profiles"].as_array().unwrap();
1178 assert_eq!(listed_profiles.len(), 1);
1179 assert_eq!(listed_profiles[0]["name"], "coding");
1180 assert_eq!(listed_profiles[0]["source"], "config");
1181 assert_eq!(listed_profiles[0]["model_id"], "test:coding");
1182 assert!(!listed_profiles
1183 .iter()
1184 .any(|profile| profile["model_id"] == "local_echo"));
1185
1186 let selected = request(
1187 &config,
1188 2,
1189 "model.select",
1190 json!({"client":"tui", "profile":"coding"}),
1191 );
1192 assert_eq!(selected["selectedProfile"], "coding");
1193 assert_eq!(selected["modelId"], "test:coding");
1194
1195 let current = request(&config, 3, "model.current", json!({"client":"tui"}));
1196 assert_eq!(current["selectedProfile"], "coding");
1197 assert_eq!(current["modelId"], "test:coding");
1198 }
1199
1200 #[test]
1201 fn config_get_and_run_prompt_smoke_through_rpc_dispatch() {
1202 let temp = tempfile::tempdir().unwrap();
1203 let config = test_config(temp.path());
1204
1205 let values = request(
1206 &config,
1207 1,
1208 "config.get",
1209 json!({"keys": ["general.default_profile", "storage.database_path"]}),
1210 );
1211 assert_eq!(
1212 values["values"]["general.default_profile"],
1213 config.default_profile
1214 );
1215 assert_eq!(
1216 values["values"]["storage.database_path"],
1217 config.database_path.display().to_string()
1218 );
1219
1220 let run = request(
1221 &config,
1222 2,
1223 "run.prompt",
1224 json!({"prompt":"hello from rpc", "newSession": true, "client":"tui"}),
1225 );
1226 assert!(run["sessionId"].as_str().unwrap().starts_with("session_"));
1227 assert!(run["runId"].as_str().unwrap().starts_with("run_"));
1228 assert_eq!(run["status"], "completed");
1229 assert!(run["latestCursor"]["sequence"].as_u64().is_some());
1230
1231 let replay = request(
1232 &config,
1233 3,
1234 "session.replay",
1235 json!({"sessionId": run["sessionId"].as_str().unwrap()}),
1236 );
1237 assert!(replay["messages"].as_array().unwrap().len() > 1);
1238 assert_eq!(
1239 replay["scope"],
1240 format!("session:{}", run["sessionId"].as_str().unwrap())
1241 );
1242 assert!(replay["events"].as_array().unwrap().len() > 1);
1243 assert!(replay["latestCursor"]["sequence"].as_u64().is_some());
1244
1245 let tail = request(
1246 &config,
1247 4,
1248 "session.replay",
1249 json!({
1250 "sessionId": run["sessionId"].as_str().unwrap(),
1251 "cursor": replay["latestCursor"],
1252 }),
1253 );
1254 assert_eq!(tail["messages"].as_array().unwrap().len(), 0);
1255 }
1256
1257 #[test]
1258 fn stream_methods_cover_replay_subscribe_and_unsubscribe() {
1259 let temp = tempfile::tempdir().unwrap();
1260 let config = test_config(temp.path());
1261 let created = request(&config, 1, "session.create", json!({"title": "stream rpc"}));
1262 let session_id = created["session"]["session_id"].as_str().unwrap();
1263
1264 let replay = request(
1265 &config,
1266 2,
1267 "stream.replay",
1268 json!({"sessionId": session_id}),
1269 );
1270 assert_eq!(replay["sessionId"], session_id);
1271 assert_eq!(replay["scope"], format!("session:{session_id}"));
1272 assert_eq!(replay["events"].as_array().unwrap().len(), 0);
1273
1274 let subscribed = request(
1275 &config,
1276 3,
1277 "stream.subscribe",
1278 json!({
1279 "sessionId": session_id,
1280 "subscriptionId": "sub_stream_test",
1281 "payloadFormat": "display_message"
1282 }),
1283 );
1284 assert_eq!(subscribed["subscriptionId"], "sub_stream_test");
1285 assert_eq!(subscribed["sessionId"], session_id);
1286 assert_eq!(subscribed["active"], false);
1287 assert_eq!(subscribed["payloadFormat"], "display_message");
1288
1289 let unsubscribed = request(
1290 &config,
1291 4,
1292 "stream.unsubscribe",
1293 json!({"subscriptionId": "sub_stream_test"}),
1294 );
1295 assert_eq!(unsubscribed["subscriptionId"], "sub_stream_test");
1296 assert_eq!(unsubscribed["unsubscribed"], false);
1297 }
1298
1299 #[test]
1300 fn stream_subscribe_requires_live_notification_transport() {
1301 let temp = tempfile::tempdir().unwrap();
1302 let config = test_config(temp.path());
1303 let server = RpcService::replay_only(config, transport::closed_notification_sender());
1304 let line = json!({
1305 "jsonrpc": "2.0",
1306 "id": 1,
1307 "method": "stream.subscribe",
1308 "params": {"sessionId": "session_test"},
1309 })
1310 .to_string();
1311 let (response, shutdown) = server.handle_line(&line);
1312 assert!(!shutdown);
1313 let response = response.unwrap();
1314 assert_eq!(response["id"], 1);
1315 assert_eq!(response["error"]["code"], UNSUPPORTED_FEATURE);
1316 assert!(response["error"]["message"]
1317 .as_str()
1318 .unwrap()
1319 .contains("live notification transport"));
1320 }
1321
1322 #[test]
1323 fn run_start_streams_agui_payloads_and_session_output_replays() {
1324 let temp = tempfile::tempdir().unwrap();
1325 let config = test_config(temp.path());
1326 let (output_sender, output_receiver) = mpsc::channel::<Value>();
1327 let server = RpcService::new(config, output_sender);
1328
1329 let started = request_with_server(
1330 &server,
1331 1,
1332 "run.start",
1333 json!({"prompt": "hello live rpc", "newSession": true}),
1334 );
1335 let session_id = started["sessionId"].as_str().unwrap().to_string();
1336 let run_id = started["runId"].as_str().unwrap().to_string();
1337 assert_eq!(started["status"], "running");
1338 assert_eq!(started["payloadFormat"], "agui");
1339
1340 let mut saw_agui_output = false;
1341 let mut saw_terminal_status = false;
1342 for _ in 0..100 {
1343 let frame = output_receiver
1344 .recv_timeout(std::time::Duration::from_secs(2))
1345 .unwrap();
1346 match frame["method"].as_str() {
1347 Some("run.output") => {
1348 assert_eq!(frame["params"]["payloadFormat"], "agui");
1349 assert!(frame["params"]["payload"]["type"].is_string());
1350 saw_agui_output = true;
1351 }
1352 Some("run.status") if frame["params"]["status"] == "completed" => {
1353 saw_terminal_status = true;
1354 break;
1355 }
1356 _ => {}
1357 }
1358 }
1359 assert!(saw_agui_output);
1360 assert!(saw_terminal_status);
1361
1362 let output = request_with_server(
1363 &server,
1364 2,
1365 "session.output",
1366 json!({"sessionId": session_id, "runId": run_id}),
1367 );
1368 assert_eq!(output["payloadFormat"], "agui");
1369 let events = output["events"].as_array().unwrap();
1370 assert!(!events.is_empty());
1371 assert!(events
1372 .iter()
1373 .any(|event| event["payload"]["type"] == "RUN_FINISHED"));
1374 }
1375
1376 #[test]
1377 fn rpc_run_prompt_expands_configured_slash_command() {
1378 let temp = tempfile::tempdir().unwrap();
1379 let global = temp.path().join("global");
1380 std::fs::create_dir_all(&global).unwrap();
1381 std::fs::write(
1382 global.join("config.toml"),
1383 r#"
1384[general]
1385model = "local_echo"
1386
1387[commands.review]
1388description = "Review changes"
1389aliases = ["rv"]
1390prompt = "Review via RPC."
1391"#,
1392 )
1393 .unwrap();
1394 let config = test_config(temp.path());
1395
1396 let run = request(
1397 &config,
1398 1,
1399 "run.prompt",
1400 json!({
1401 "prompt":"/rv staged diff",
1402 "newSession": true,
1403 "profile":"default_model",
1404 }),
1405 );
1406 assert_eq!(run["status"], "completed");
1407
1408 let store = crate::LocalStore::open(&config).unwrap();
1409 let run_record = store
1410 .load_run(
1411 run["sessionId"].as_str().unwrap(),
1412 run["runId"].as_str().unwrap(),
1413 )
1414 .unwrap();
1415 let value = serde_json::to_value(run_record).unwrap();
1416 assert_eq!(
1417 value["input"][0]["text"],
1418 "Review via RPC.\n\nUser instruction: staged diff"
1419 );
1420 assert_eq!(value["metadata"]["cli.slash_command.name"], "review");
1421 assert_eq!(value["metadata"]["cli.slash_command.invoked"], "rv");
1422 }
1423
1424 #[test]
1425 fn http_transport_dispatches_json_rpc_requests() {
1426 let temp = tempfile::tempdir().unwrap();
1427 let config = test_config(temp.path());
1428
1429 let (response, shutdown) = http_post(
1430 &config,
1431 &json!({
1432 "jsonrpc": "2.0",
1433 "id": 1,
1434 "method": "initialize",
1435 "params": {"clientInfo": {"name": "http-test"}},
1436 }),
1437 );
1438 assert!(!shutdown.load(Ordering::SeqCst));
1439 let body = http_body(&response);
1440 assert_eq!(body["jsonrpc"], "2.0");
1441 assert_eq!(body["id"], 1);
1442 assert_eq!(body["result"]["protocolVersion"], PROTOCOL_VERSION);
1443 assert_eq!(body["result"]["serverInfo"]["name"], "starweaver-cli");
1444 }
1445
1446 #[test]
1447 fn http_transport_shutdown_marks_server_shutdown() {
1448 let temp = tempfile::tempdir().unwrap();
1449 let config = test_config(temp.path());
1450
1451 let (response, shutdown) = http_post(
1452 &config,
1453 &json!({
1454 "jsonrpc": "2.0",
1455 "id": 7,
1456 "method": "shutdown",
1457 "params": {},
1458 }),
1459 );
1460 assert!(shutdown.load(Ordering::SeqCst));
1461 let body = http_body(&response);
1462 assert_eq!(body["id"], 7);
1463 assert_eq!(body["result"]["status"], "shutdown");
1464 }
1465}