Skip to main content

starweaver_cli/
rpc.rs

1//! JSON-RPC host service and local transports.
2
3mod 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
42/// Run the selected JSON-RPC host transport.
43pub 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", &params)
735            }
736            RunStreamEvent::Status(status) => {
737                let mut params = json!(status);
738                insert_subscription_id(&mut params, subscription_id);
739                notification("stream.status", &params)
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}