Skip to main content

cccc_sdk/
client.rs

1use std::collections::BTreeMap;
2use std::io::{BufRead, BufReader, Read, Write};
3use std::net::{TcpStream, ToSocketAddrs};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, RwLock};
6use std::time::Duration;
7
8use serde::de::DeserializeOwned;
9use serde_json::{json, Map, Value};
10
11use crate::{
12    discover_endpoint, ContextDetail, DaemonEndpoint, DaemonRequest, DaemonResponse, Error,
13    MessageHistoryMode, MessageMode, PingResult, ReplyMessageMode, Result, TerminalHistoryOptions,
14    TerminalHistoryResult, TerminalResizeResult, TerminalSinceOptions, TerminalSinceResult,
15    TerminalSnapshotOptions, TerminalSnapshotResult, WebModelDeliveryMode,
16    WebModelDeliveryPreferencesResult, WebModelRuntimeRecoverTurnResult,
17};
18
19const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
20const MAX_REQUEST_BYTES: usize = 2_000_000;
21const MAX_RESPONSE_BYTES: usize = 4_000_000;
22
23enum CallAttemptError {
24    Connect(Error),
25    Exchange(Error),
26}
27
28/// Capabilities and operations required by [`CCCCClient::assert_compatible`].
29#[derive(Clone, Debug, Default)]
30pub struct CompatibilityRequirements<'a> {
31    pub minimum_ipc_version: u32,
32    pub capabilities: BTreeMap<&'a str, bool>,
33    pub operations: Vec<&'a str>,
34}
35
36/// Blocking CCCC Daemon IPC v1 client.
37#[derive(Clone, Debug)]
38pub struct CCCCClient {
39    endpoint: DaemonEndpoint,
40    rediscovered_endpoint: Arc<RwLock<Option<DaemonEndpoint>>>,
41    timeout: Duration,
42    discovery_home: Option<PathBuf>,
43    rediscover_on_connect_failure: bool,
44}
45
46impl CCCCClient {
47    /// Discover the currently running daemon.
48    pub fn discover() -> Result<Self> {
49        Self::discover_in(None)
50    }
51
52    /// Discover a daemon under an explicit `CCCC_HOME`.
53    pub fn discover_in(cccc_home: Option<&Path>) -> Result<Self> {
54        Ok(Self {
55            endpoint: discover_endpoint(cccc_home)?,
56            rediscovered_endpoint: Arc::new(RwLock::new(None)),
57            timeout: DEFAULT_TIMEOUT,
58            discovery_home: cccc_home.map(Path::to_path_buf),
59            rediscover_on_connect_failure: true,
60        })
61    }
62
63    pub fn new(endpoint: DaemonEndpoint) -> Self {
64        Self {
65            endpoint,
66            rediscovered_endpoint: Arc::new(RwLock::new(None)),
67            timeout: DEFAULT_TIMEOUT,
68            discovery_home: None,
69            rediscover_on_connect_failure: false,
70        }
71    }
72
73    pub fn with_timeout(mut self, timeout: Duration) -> Self {
74        self.timeout = timeout;
75        self
76    }
77
78    /// The endpoint supplied to `new` or found during initial discovery.
79    pub fn endpoint(&self) -> &DaemonEndpoint {
80        &self.endpoint
81    }
82
83    /// The endpoint currently used for calls, including a successful
84    /// rediscovery after daemon restart.
85    pub fn current_endpoint(&self) -> DaemonEndpoint {
86        self.rediscovered_endpoint
87            .read()
88            .unwrap_or_else(|poisoned| poisoned.into_inner())
89            .clone()
90            .unwrap_or_else(|| self.endpoint.clone())
91    }
92
93    /// Call any non-streaming daemon operation and return its result object.
94    pub fn call(&self, op: &str, args: Map<String, Value>) -> Result<Map<String, Value>> {
95        let response = self.call_raw(op, args)?;
96        if response.v != 1 {
97            return Err(Error::UnsupportedIpcVersion(response.v));
98        }
99        if response.ok {
100            return Ok(response.result);
101        }
102        Err(Error::Daemon(response.error.unwrap_or_else(|| {
103            crate::DaemonError {
104                code: "error".into(),
105                message: "daemon returned ok=false without an error".into(),
106                details: json!({}),
107            }
108        })))
109    }
110
111    /// Call an operation and retain the full response envelope.
112    pub fn call_raw(&self, op: &str, args: Map<String, Value>) -> Result<DaemonResponse> {
113        if op.trim().is_empty() {
114            return Err(Error::Incompatible("operation name cannot be empty".into()));
115        }
116        let request = DaemonRequest {
117            v: 1,
118            op,
119            args: &args,
120        };
121        let mut encoded = serde_json::to_vec(&request)?;
122        encoded.push(b'\n');
123        if encoded.len() > MAX_REQUEST_BYTES {
124            return Err(Error::RequestTooLarge(MAX_REQUEST_BYTES));
125        }
126
127        let endpoint = self.current_endpoint();
128        let response = match self.call_at(&endpoint, &encoded) {
129            Ok(response) => Ok(response),
130            Err(CallAttemptError::Connect(_)) if self.rediscover_on_connect_failure => {
131                let endpoint = discover_endpoint(self.discovery_home.as_deref())?;
132                *self
133                    .rediscovered_endpoint
134                    .write()
135                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(endpoint.clone());
136                self.call_at(&endpoint, &encoded)
137                    .map_err(|retry_error| finish_attempt_error(op, retry_error))
138            }
139            Err(error) => Err(finish_attempt_error(op, error)),
140        }?;
141        if response.v != 1 {
142            return Err(Error::UnsupportedIpcVersion(response.v));
143        }
144        Ok(response)
145    }
146
147    fn call_at(
148        &self,
149        endpoint: &DaemonEndpoint,
150        encoded: &[u8],
151    ) -> std::result::Result<DaemonResponse, CallAttemptError> {
152        match endpoint {
153            DaemonEndpoint::Tcp { host, port } => {
154                let address = (host.as_str(), *port)
155                    .to_socket_addrs()
156                    .map_err(|error| CallAttemptError::Connect(error.into()))?
157                    .next()
158                    .ok_or_else(|| Error::InvalidEndpoint(format!("cannot resolve {host}:{port}")))
159                    .map_err(CallAttemptError::Connect)?;
160                let mut stream = TcpStream::connect_timeout(&address, self.timeout)
161                    .map_err(|error| CallAttemptError::Connect(error.into()))?;
162                stream
163                    .set_read_timeout(Some(self.timeout))
164                    .map_err(|error| CallAttemptError::Connect(error.into()))?;
165                stream
166                    .set_write_timeout(Some(self.timeout))
167                    .map_err(|error| CallAttemptError::Connect(error.into()))?;
168                exchange(&mut stream, encoded).map_err(CallAttemptError::Exchange)
169            }
170            DaemonEndpoint::Unix(path) => self.call_unix(path, encoded),
171        }
172    }
173
174    #[cfg(unix)]
175    fn call_unix(
176        &self,
177        path: &Path,
178        encoded: &[u8],
179    ) -> std::result::Result<DaemonResponse, CallAttemptError> {
180        use std::os::unix::net::UnixStream;
181
182        let mut stream =
183            UnixStream::connect(path).map_err(|error| CallAttemptError::Connect(error.into()))?;
184        stream
185            .set_read_timeout(Some(self.timeout))
186            .map_err(|error| CallAttemptError::Connect(error.into()))?;
187        stream
188            .set_write_timeout(Some(self.timeout))
189            .map_err(|error| CallAttemptError::Connect(error.into()))?;
190        exchange(&mut stream, encoded).map_err(CallAttemptError::Exchange)
191    }
192
193    #[cfg(not(unix))]
194    fn call_unix(
195        &self,
196        _path: &Path,
197        _encoded: &[u8],
198    ) -> std::result::Result<DaemonResponse, CallAttemptError> {
199        Err(CallAttemptError::Connect(Error::UnixSocketUnsupported))
200    }
201
202    pub fn ping(&self) -> Result<PingResult> {
203        self.call_typed("ping", Map::new())
204    }
205
206    pub fn groups(&self) -> Result<Map<String, Value>> {
207        self.call("groups", Map::new())
208    }
209
210    pub fn group_show(&self, group_id: &str) -> Result<Map<String, Value>> {
211        self.call("group_show", object([("group_id", json!(group_id))]))
212    }
213
214    pub fn send(
215        &self,
216        group_id: &str,
217        text: &str,
218        message_mode: MessageMode,
219        by: &str,
220    ) -> Result<Map<String, Value>> {
221        self.call(
222            "send",
223            object([
224                ("group_id", json!(group_id)),
225                ("text", json!(text)),
226                ("message_mode", json!(message_mode.as_str())),
227                ("by", json!(by)),
228            ]),
229        )
230    }
231
232    pub fn reply(
233        &self,
234        group_id: &str,
235        reply_to: &str,
236        text: &str,
237        by: &str,
238    ) -> Result<Map<String, Value>> {
239        self.reply_with_mode(group_id, reply_to, text, ReplyMessageMode::Send, by)
240    }
241
242    pub fn reply_with_mode(
243        &self,
244        group_id: &str,
245        reply_to: &str,
246        text: &str,
247        message_mode: ReplyMessageMode,
248        by: &str,
249    ) -> Result<Map<String, Value>> {
250        self.call(
251            "reply",
252            object([
253                ("group_id", json!(group_id)),
254                ("reply_to", json!(reply_to)),
255                ("text", json!(text)),
256                ("message_mode", json!(message_mode.as_str())),
257                ("by", json!(by)),
258            ]),
259        )
260    }
261
262    pub fn reply_request_cancel(
263        &self,
264        group_id: &str,
265        source_event_id: &str,
266        by: &str,
267    ) -> Result<Map<String, Value>> {
268        self.call(
269            "reply_request_cancel",
270            object([
271                ("group_id", json!(group_id)),
272                ("source_event_id", json!(source_event_id)),
273                ("by", json!(by)),
274            ]),
275        )
276    }
277
278    pub fn message_deliver(
279        &self,
280        group_id: &str,
281        source_event_id: &str,
282        actor_ids: &[&str],
283        by: &str,
284        force_ambiguous: bool,
285    ) -> Result<Map<String, Value>> {
286        if actor_ids.is_empty() || actor_ids.iter().any(|actor_id| actor_id.trim().is_empty()) {
287            return Err(Error::InvalidArgument(
288                "message_deliver requires one or more non-empty actor_ids".into(),
289            ));
290        }
291        self.call(
292            "message_deliver",
293            object([
294                ("group_id", json!(group_id)),
295                ("source_event_id", json!(source_event_id)),
296                ("actor_ids", json!(actor_ids)),
297                ("by", json!(by)),
298                ("force_ambiguous", json!(force_ambiguous)),
299            ]),
300        )
301    }
302
303    pub fn inbox_peek(
304        &self,
305        group_id: &str,
306        actor_id: &str,
307        by: &str,
308        limit: Option<u32>,
309    ) -> Result<Map<String, Value>> {
310        let mut args = object([
311            ("group_id", json!(group_id)),
312            ("actor_id", json!(actor_id)),
313            ("by", json!(by)),
314        ]);
315        if let Some(limit) = limit {
316            args.insert("limit".into(), json!(limit));
317        }
318        self.call("inbox_peek", args)
319    }
320
321    pub fn inbox_read(
322        &self,
323        group_id: &str,
324        actor_id: &str,
325        by: &str,
326        limit: Option<u32>,
327    ) -> Result<Map<String, Value>> {
328        let mut args = object([
329            ("group_id", json!(group_id)),
330            ("actor_id", json!(actor_id)),
331            ("by", json!(by)),
332        ]);
333        if let Some(limit) = limit {
334            args.insert("limit".into(), json!(limit));
335        }
336        self.call("inbox_read", args)
337    }
338
339    /// Inspect actor-visible chat history without consuming Mail.
340    #[allow(clippy::too_many_arguments)]
341    pub fn message_history(
342        &self,
343        group_id: &str,
344        actor_id: &str,
345        by: &str,
346        mode: MessageHistoryMode,
347        query: Option<&str>,
348        before_event_id: Option<&str>,
349        limit: Option<u32>,
350    ) -> Result<Map<String, Value>> {
351        let mut args = object([
352            ("group_id", json!(group_id)),
353            ("actor_id", json!(actor_id)),
354            ("by", json!(by)),
355            ("mode", json!(mode.as_str())),
356        ]);
357        if let Some(query) = query.filter(|value| !value.is_empty()) {
358            args.insert("query".into(), json!(query));
359        }
360        if let Some(before_event_id) = before_event_id.filter(|value| !value.is_empty()) {
361            args.insert("before_event_id".into(), json!(before_event_id));
362        }
363        if let Some(limit) = limit {
364            args.insert("limit".into(), json!(limit));
365        }
366        self.call("message_history", args)
367    }
368
369    pub fn context_get(&self, group_id: &str) -> Result<Map<String, Value>> {
370        self.context_get_with_detail(group_id, ContextDetail::Full)
371    }
372
373    pub fn context_get_with_detail(
374        &self,
375        group_id: &str,
376        detail: ContextDetail,
377    ) -> Result<Map<String, Value>> {
378        self.call(
379            "context_get",
380            object([
381                ("group_id", json!(group_id)),
382                ("detail", json!(detail.as_str())),
383            ]),
384        )
385    }
386
387    pub fn context_sync(
388        &self,
389        group_id: &str,
390        by: &str,
391        operations: Vec<Value>,
392    ) -> Result<Map<String, Value>> {
393        self.context_sync_checked(group_id, by, operations, None, false)
394    }
395
396    pub fn context_sync_checked(
397        &self,
398        group_id: &str,
399        by: &str,
400        operations: Vec<Value>,
401        if_version: Option<&str>,
402        dry_run: bool,
403    ) -> Result<Map<String, Value>> {
404        let mut args = object([
405            ("group_id", json!(group_id)),
406            ("by", json!(by)),
407            ("ops", Value::Array(operations)),
408            ("dry_run", json!(dry_run)),
409        ]);
410        if let Some(version) = if_version {
411            args.insert("if_version".into(), json!(version));
412        }
413        self.call("context_sync", args)
414    }
415
416    pub fn terminal_history(
417        &self,
418        group_id: &str,
419        actor_id: &str,
420        options: &TerminalHistoryOptions,
421    ) -> Result<TerminalHistoryResult> {
422        let mut args = object([("group_id", json!(group_id)), ("actor_id", json!(actor_id))]);
423        if let Some(render_before) = options.render_before {
424            args.insert("render_before".into(), json!(render_before));
425        }
426        if let Some(before) = options.before {
427            args.insert("before".into(), json!(before));
428        }
429        if let Some(limit_bytes) = options.limit_bytes {
430            args.insert("limit_bytes".into(), json!(limit_bytes));
431        }
432        if let Some(strip_ansi) = options.strip_ansi {
433            args.insert("strip_ansi".into(), json!(strip_ansi));
434        }
435        if let Some(compact) = options.compact {
436            args.insert("compact".into(), json!(compact));
437        }
438        if let Some(by) = options.by.as_deref() {
439            args.insert("by".into(), json!(by));
440        }
441        self.call_typed("terminal_history", args)
442    }
443
444    pub fn terminal_since(
445        &self,
446        group_id: &str,
447        actor_id: &str,
448        after: u64,
449        options: &TerminalSinceOptions,
450    ) -> Result<TerminalSinceResult> {
451        let mut args = object([
452            ("group_id", json!(group_id)),
453            ("actor_id", json!(actor_id)),
454            ("after", json!(after)),
455        ]);
456        if let Some(limit_bytes) = options.limit_bytes {
457            args.insert("limit_bytes".into(), json!(limit_bytes));
458        }
459        if let Some(by) = options.by.as_deref() {
460            args.insert("by".into(), json!(by));
461        }
462        self.call_typed("terminal_since", args)
463    }
464
465    pub fn terminal_snapshot(
466        &self,
467        group_id: &str,
468        actor_id: &str,
469        options: &TerminalSnapshotOptions,
470    ) -> Result<TerminalSnapshotResult> {
471        let mut args = object([("group_id", json!(group_id)), ("actor_id", json!(actor_id))]);
472        if let Some(limit_bytes) = options.limit_bytes {
473            args.insert("limit_bytes".into(), json!(limit_bytes));
474        }
475        if let Some(by) = options.by.as_deref() {
476            args.insert("by".into(), json!(by));
477        }
478        self.call_typed("terminal_snapshot", args)
479    }
480
481    /// Resize a PTY using the normative op, with a compatibility fallback for
482    /// Rust daemon builds that temporarily exposed `terminal_resize` instead.
483    pub fn term_resize(
484        &self,
485        group_id: &str,
486        actor_id: &str,
487        cols: u32,
488        rows: u32,
489    ) -> Result<TerminalResizeResult> {
490        let args = object([
491            ("group_id", json!(group_id)),
492            ("actor_id", json!(actor_id)),
493            ("cols", json!(cols)),
494            ("rows", json!(rows)),
495        ]);
496        match self.call_typed("term_resize", args.clone()) {
497            Err(Error::Daemon(error)) if error.code == "unknown_op" => {
498                let legacy = self.call("terminal_resize", args)?;
499                let resolved_cols = legacy
500                    .get("cols")
501                    .and_then(Value::as_u64)
502                    .and_then(|value| u32::try_from(value).ok())
503                    .unwrap_or(cols);
504                let resolved_rows = legacy
505                    .get("rows")
506                    .and_then(Value::as_u64)
507                    .and_then(|value| u32::try_from(value).ok())
508                    .unwrap_or(rows);
509                Ok(TerminalResizeResult {
510                    group_id: group_id.to_owned(),
511                    actor_id: actor_id.to_owned(),
512                    cols: resolved_cols,
513                    rows: resolved_rows,
514                })
515            }
516            result => result,
517        }
518    }
519
520    pub fn web_model_delivery_preferences_get(
521        &self,
522        group_id: &str,
523        actor_id: &str,
524    ) -> Result<WebModelDeliveryPreferencesResult> {
525        self.call_typed(
526            "web_model_delivery_preferences_get",
527            object([("group_id", json!(group_id)), ("actor_id", json!(actor_id))]),
528        )
529    }
530
531    pub fn web_model_delivery_preferences_update(
532        &self,
533        group_id: &str,
534        actor_id: &str,
535        mode: WebModelDeliveryMode,
536        by: &str,
537    ) -> Result<WebModelDeliveryPreferencesResult> {
538        self.call_typed(
539            "web_model_delivery_preferences_update",
540            object([
541                ("group_id", json!(group_id)),
542                ("actor_id", json!(actor_id)),
543                ("mode", json!(mode)),
544                ("by", json!(by)),
545            ]),
546        )
547    }
548
549    pub fn web_model_runtime_recover_turn(
550        &self,
551        group_id: &str,
552        actor_id: &str,
553        event_ids: &[String],
554    ) -> Result<WebModelRuntimeRecoverTurnResult> {
555        if event_ids.is_empty() || event_ids.iter().any(|event_id| event_id.trim().is_empty()) {
556            return Err(Error::InvalidArgument(
557                "event_ids must contain at least one non-empty event id".into(),
558            ));
559        }
560        self.call_typed(
561            "web_model_runtime_recover_turn",
562            object([
563                ("group_id", json!(group_id)),
564                ("actor_id", json!(actor_id)),
565                ("event_ids", json!(event_ids)),
566            ]),
567        )
568    }
569
570    /// Validate protocol, advertised capabilities, and actual op recognition.
571    pub fn assert_compatible(
572        &self,
573        requirements: &CompatibilityRequirements<'_>,
574    ) -> Result<PingResult> {
575        let ping = self.ping()?;
576        let minimum = requirements.minimum_ipc_version.max(1);
577        if ping.ipc_v < minimum {
578            return Err(Error::Incompatible(format!(
579                "daemon ipc_v={} is below required ipc_v={minimum}",
580                ping.ipc_v
581            )));
582        }
583        for (name, required) in &requirements.capabilities {
584            if *required && ping.capabilities.get(*name).and_then(Value::as_bool) != Some(true) {
585                return Err(Error::Incompatible(format!(
586                    "daemon capability {name}=true is required"
587                )));
588            }
589        }
590        for operation in &requirements.operations {
591            if *operation == "ping" {
592                continue;
593            }
594            let advertised = ping.capabilities.get(*operation).and_then(Value::as_bool);
595            if advertised == Some(false) {
596                return Err(Error::Incompatible(format!(
597                    "daemon does not support operation {operation}"
598                )));
599            }
600            if !operation_probe_is_safe(operation) {
601                if advertised == Some(true) {
602                    continue;
603                }
604                return Err(Error::Incompatible(format!("cannot safely verify operation {operation}; no advertised capability or safe probe")));
605            }
606            match self.call(operation, Map::new()) {
607                Err(Error::Daemon(error)) if error.code == "unknown_op" => {
608                    if *operation == "term_resize" {
609                        match self.call("terminal_resize", Map::new()) {
610                            Err(Error::Daemon(alias_error)) if alias_error.code == "unknown_op" => {
611                            }
612                            Err(Error::Daemon(_)) | Ok(_) => continue,
613                            Err(error) => return Err(error),
614                        }
615                    }
616                    return Err(Error::Incompatible(format!(
617                        "daemon does not support operation {operation}"
618                    )));
619                }
620                Err(Error::Daemon(_)) | Ok(_) => {}
621                Err(error) => return Err(error),
622            }
623        }
624        Ok(ping)
625    }
626
627    fn call_typed<T: DeserializeOwned>(&self, op: &str, args: Map<String, Value>) -> Result<T> {
628        let result = self.call(op, args)?;
629        Ok(serde_json::from_value(Value::Object(result))?)
630    }
631}
632
633// Explicitly reviewed empty-argument probes; unknown operations must never run.
634fn operation_probe_is_safe(operation: &str) -> bool {
635    matches!(
636        operation,
637        "groups"
638            | "group_show"
639            | "group_preamble_get"
640            | "group_preamble_set"
641            | "group_preamble_reset"
642            | "send"
643            | "tracked_send"
644            | "send_files"
645            | "reply"
646            | "inbox_peek"
647            | "inbox_read"
648            | "context_get"
649            | "context_sync"
650            | "message_deliver"
651            | "message_history"
652            | "reply_request_cancel"
653            | "send_cross_group"
654            | "memory_search"
655            | "memory_get"
656            | "memory_write"
657            | "memory_profile_get"
658            | "memory_health"
659            | "actor_new_session"
660            | "group_reset"
661            | "group_copy_export_file"
662            | "terminal_history"
663            | "terminal_since"
664            | "terminal_snapshot"
665            | "term_resize"
666            | "web_model_delivery_preferences_get"
667            | "web_model_delivery_preferences_update"
668            | "web_model_runtime_recover_turn"
669            | "assistant_voice_document_library"
670            | "assistant_voice_document_library_update"
671            | "assistant_voice_document_delete"
672            | "events_stream"
673            | "connect_catalog"
674            | "connect_send"
675            | "connect_send_files"
676    )
677}
678
679fn finish_attempt_error(op: &str, error: CallAttemptError) -> Error {
680    match error {
681        CallAttemptError::Connect(error) => error,
682        CallAttemptError::Exchange(error) => Error::OutcomeUnknown {
683            op: op.to_owned(),
684            message: error.to_string(),
685        },
686    }
687}
688
689fn object<const N: usize>(entries: [(&str, Value); N]) -> Map<String, Value> {
690    entries
691        .into_iter()
692        .map(|(key, value)| (key.to_owned(), value))
693        .collect()
694}
695
696fn exchange<S: Read + Write>(stream: &mut S, request: &[u8]) -> Result<DaemonResponse> {
697    stream.write_all(request)?;
698    stream.flush()?;
699
700    let mut reader = BufReader::new(stream);
701    let mut response = Vec::new();
702    let read = reader
703        .by_ref()
704        .take((MAX_RESPONSE_BYTES + 1) as u64)
705        .read_until(b'\n', &mut response)?;
706    if read == 0 {
707        return Err(Error::EmptyResponse);
708    }
709    if response.len() > MAX_RESPONSE_BYTES {
710        return Err(Error::ResponseTooLarge(MAX_RESPONSE_BYTES));
711    }
712    Ok(serde_json::from_slice(&response)?)
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use std::env;
719    use std::fs;
720    use std::net::TcpListener;
721    use std::thread;
722    use std::time::{SystemTime, UNIX_EPOCH};
723
724    fn server_once(response: &'static str) -> (DaemonEndpoint, thread::JoinHandle<String>) {
725        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
726        let address = listener.local_addr().expect("local address");
727        let handle = thread::spawn(move || {
728            let (mut stream, _) = listener.accept().expect("accept client");
729            let mut request = String::new();
730            BufReader::new(&mut stream)
731                .read_line(&mut request)
732                .expect("read request");
733            stream
734                .write_all(response.as_bytes())
735                .expect("write response");
736            request
737        });
738        (
739            DaemonEndpoint::Tcp {
740                host: "127.0.0.1".into(),
741                port: address.port(),
742            },
743            handle,
744        )
745    }
746
747    fn server_sequence(
748        responses: Vec<&'static str>,
749    ) -> (DaemonEndpoint, thread::JoinHandle<Vec<String>>) {
750        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
751        let address = listener.local_addr().expect("local address");
752        let handle = thread::spawn(move || {
753            responses
754                .into_iter()
755                .map(|response| {
756                    let (mut stream, _) = listener.accept().expect("accept client");
757                    let mut request = String::new();
758                    BufReader::new(&mut stream)
759                        .read_line(&mut request)
760                        .expect("read request");
761                    stream
762                        .write_all(response.as_bytes())
763                        .expect("write response");
764                    request
765                })
766                .collect()
767        });
768        (
769            DaemonEndpoint::Tcp {
770                host: "127.0.0.1".into(),
771                port: address.port(),
772            },
773            handle,
774        )
775    }
776
777    fn temp_home(label: &str) -> PathBuf {
778        let nonce = SystemTime::now()
779            .duration_since(UNIX_EPOCH)
780            .expect("clock")
781            .as_nanos();
782        env::temp_dir().join(format!(
783            "cccc-sdk-client-{label}-{}-{nonce}",
784            std::process::id()
785        ))
786    }
787
788    fn write_tcp_descriptor(home: &Path, port: u16) {
789        let daemon = home.join("daemon");
790        fs::create_dir_all(&daemon).expect("create daemon directory");
791        fs::write(
792            daemon.join("ccccd.addr.json"),
793            format!(r#"{{"v":1,"transport":"tcp","host":"127.0.0.1","port":{port}}}"#),
794        )
795        .expect("write descriptor");
796    }
797
798    #[test]
799    fn sends_a_valid_ping_envelope() {
800        let (endpoint, server) = server_once(
801            "{\"v\":1,\"ok\":true,\"result\":{\"version\":\"0.4.33\",\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
802        );
803        let ping = CCCCClient::new(endpoint).ping().expect("ping");
804        assert_eq!(ping.version, "0.4.33");
805        assert_eq!(ping.ipc_v, 1);
806
807        let request: Value =
808            serde_json::from_str(&server.join().expect("server thread")).expect("request JSON");
809        assert_eq!(request, json!({"v": 1, "op": "ping", "args": {}}));
810    }
811
812    #[test]
813    fn voice_library_preserves_actions_and_empty_values_on_the_wire() {
814        use crate::VoiceDocumentLibraryAction as Action;
815        let cases = [
816            (
817                Action::CreateFolder {
818                    name: "Meetings".into(),
819                },
820                json!({"action":"create_folder","name":"Meetings"}),
821            ),
822            (
823                Action::RenameFolder {
824                    folder_id: "f_1".into(),
825                    name: "Archive".into(),
826                },
827                json!({"action":"rename_folder","folder_id":"f_1","name":"Archive"}),
828            ),
829            (
830                Action::RemoveFolder {
831                    folder_id: "f_1".into(),
832                },
833                json!({"action":"remove_folder","folder_id":"f_1"}),
834            ),
835            (
836                Action::ReorderRoot {
837                    root_order: vec!["folder:f_1".into(), "document:notes.md".into()],
838                },
839                json!({"action":"reorder_root","root_order":["folder:f_1","document:notes.md"]}),
840            ),
841            (
842                Action::ReorderRoot { root_order: vec![] },
843                json!({"action":"reorder_root","root_order":[]}),
844            ),
845            (
846                Action::Rename {
847                    document_path: "notes.md".into(),
848                    name: "Summary".into(),
849                },
850                json!({"action":"rename","document_path":"notes.md","name":"Summary"}),
851            ),
852            (
853                Action::Move {
854                    document_path: "notes.md".into(),
855                    folder_id: "".into(),
856                },
857                json!({"action":"move","document_path":"notes.md","folder_id":""}),
858            ),
859            (
860                Action::Restore {
861                    document_path: "notes.md".into(),
862                },
863                json!({"action":"restore","document_path":"notes.md"}),
864            ),
865        ];
866        let response = "{\"v\":1,\"ok\":true,\"result\":{\"folders\":[],\"root_order\":[],\"documents\":[{\"status\":\"archived\"}]}}\n";
867        let (endpoint, server) = server_sequence(vec![response; cases.len() + 1]);
868        let client = CCCCClient::new(endpoint);
869        let library = client
870            .assistant_voice_document_library("g_1")
871            .expect("library");
872        assert_eq!(library.documents[0]["status"], "archived");
873        for (action, _) in &cases {
874            client
875                .assistant_voice_document_library_update("g_1", action, Some("foreman"))
876                .expect("update");
877        }
878        let requests: Vec<Value> = server
879            .join()
880            .expect("server")
881            .iter()
882            .map(|line| serde_json::from_str(line).expect("JSON"))
883            .collect();
884        assert_eq!(
885            requests[0],
886            json!({"v":1,"op":"assistant_voice_document_library","args":{"group_id":"g_1"}})
887        );
888        for (index, (_, mut expected)) in cases.into_iter().enumerate() {
889            expected["group_id"] = json!("g_1");
890            expected["by"] = json!("foreman");
891            assert_eq!(
892                requests[index + 1],
893                json!({"v":1,"op":"assistant_voice_document_library_update","args":expected})
894            );
895        }
896    }
897
898    #[test]
899    fn voice_delete_preserves_recording_error() {
900        let (endpoint, server) = server_once("{\"v\":1,\"ok\":false,\"error\":{\"code\":\"voice_recording_active\",\"message\":\"Stop recording\"}}\n");
901        let error = CCCCClient::new(endpoint)
902            .assistant_voice_document_delete("g_1", "notes.md", None)
903            .expect_err("recording");
904        assert!(
905            matches!(error, Error::Daemon(crate::DaemonError { ref code, .. }) if code == "voice_recording_active")
906        );
907        let request: Value = serde_json::from_str(&server.join().expect("server")).expect("JSON");
908        assert_eq!(
909            request,
910            json!({"v":1,"op":"assistant_voice_document_delete","args":{"group_id":"g_1","document_path":"notes.md","by":"user"}})
911        );
912    }
913
914    #[test]
915    fn preserves_structured_daemon_errors() {
916        let (endpoint, server) = server_once(
917            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
918        );
919        let error = CCCCClient::new(endpoint)
920            .call("future_op", Map::new())
921            .expect_err("daemon error");
922        assert!(matches!(
923            error,
924            Error::Daemon(crate::DaemonError { ref code, .. }) if code == "unknown_op"
925        ));
926        server.join().expect("server thread");
927    }
928
929    #[test]
930    fn maps_current_message_delivery_and_inbox_operations() {
931        let response = "{\"v\":1,\"ok\":true,\"result\":{}}\n";
932        let (endpoint, server) = server_sequence(vec![response; 7]);
933        let client = CCCCClient::new(endpoint);
934
935        client
936            .send("g_1", "FYI", MessageMode::Mail, "user")
937            .expect("send Mail");
938        client
939            .reply_with_mode(
940                "g_1",
941                "e_request",
942                "quiet follow-up",
943                ReplyMessageMode::Mail,
944                "peer-1",
945            )
946            .expect("reply with Mail");
947        client
948            .reply_request_cancel("g_1", "e_request", "user")
949            .expect("cancel reply request");
950        client
951            .message_deliver("g_1", "e_mail", &["peer-1"], "user", true)
952            .expect("deliver existing message");
953        client
954            .inbox_peek("g_1", "peer-1", "user", Some(5))
955            .expect("peek Inbox");
956        client
957            .inbox_read("g_1", "peer-1", "peer-1", Some(3))
958            .expect("read Inbox");
959        client
960            .message_history(
961                "g_1",
962                "peer-1",
963                "user",
964                MessageHistoryMode::Send,
965                Some("decision"),
966                Some("e_before"),
967                Some(7),
968            )
969            .expect("read message history");
970
971        let requests: Vec<Value> = server
972            .join()
973            .expect("server thread")
974            .iter()
975            .map(|request| serde_json::from_str(request).expect("request JSON"))
976            .collect();
977        assert_eq!(requests[0]["args"]["message_mode"], "mail");
978        assert_eq!(requests[1]["op"], "reply");
979        assert_eq!(requests[1]["args"]["message_mode"], "mail");
980        assert_eq!(requests[2]["op"], "reply_request_cancel");
981        assert_eq!(requests[3]["args"]["actor_ids"], json!(["peer-1"]));
982        assert_eq!(requests[3]["args"]["force_ambiguous"], true);
983        assert_eq!(requests[4]["op"], "inbox_peek");
984        assert_eq!(requests[5]["op"], "inbox_read");
985        assert_eq!(requests[6]["op"], "message_history");
986        assert_eq!(requests[6]["args"]["mode"], "send");
987        assert_eq!(requests[6]["args"]["query"], "decision");
988        assert_eq!(requests[6]["args"]["before_event_id"], "e_before");
989        assert_eq!(requests[6]["args"]["limit"], 7);
990        assert!(matches!(
991            client.message_deliver("g_1", "e_mail", &[], "user", false),
992            Err(Error::InvalidArgument(_))
993        ));
994    }
995
996    #[test]
997    fn context_get_uses_the_current_projection_contract() {
998        let (endpoint, server) = server_once("{\"v\":1,\"ok\":true,\"result\":{}}\n");
999        CCCCClient::new(endpoint)
1000            .context_get_with_detail("g_1", ContextDetail::Summary)
1001            .expect("summary context");
1002        let request: Value =
1003            serde_json::from_str(&server.join().expect("server thread")).expect("request JSON");
1004        assert_eq!(
1005            request,
1006            json!({"v": 1, "op": "context_get", "args": {"group_id": "g_1", "detail": "summary"}})
1007        );
1008    }
1009
1010    #[test]
1011    fn never_probes_destructive_or_streaming_operations() {
1012        assert!(!operation_probe_is_safe("shutdown"));
1013        assert!(!operation_probe_is_safe("group_create"));
1014        assert!(!operation_probe_is_safe("remote_access_start"));
1015        assert!(!operation_probe_is_safe("term_attach"));
1016        assert!(operation_probe_is_safe("group_show"));
1017    }
1018
1019    #[test]
1020    fn call_raw_rejects_an_unsupported_response_version() {
1021        let (endpoint, server) = server_once("{\"v\":2,\"ok\":true,\"result\":{}}\n");
1022        let error = CCCCClient::new(endpoint)
1023            .call_raw("ping", Map::new())
1024            .expect_err("unsupported version");
1025        assert!(matches!(error, Error::UnsupportedIpcVersion(2)));
1026        server.join().expect("server thread");
1027    }
1028
1029    #[test]
1030    fn maps_current_terminal_and_web_model_operations() {
1031        let preference = "{\"v\":1,\"ok\":true,\"result\":{\"group_id\":\"g_1\",\"actor_id\":\"web-1\",\"preference\":{\"mode\":\"image_compat\",\"updated_at\":\"now\",\"updated_by\":\"user\"}}}\n";
1032        let (endpoint, server) = server_sequence(vec![
1033            "{\"v\":1,\"ok\":true,\"result\":{\"data\":\"screen\",\"start_cursor\":1,\"end_cursor\":7}}\n",
1034            preference,
1035            preference,
1036            "{\"v\":1,\"ok\":true,\"result\":{\"status\":\"recovered\",\"turn\":{\"turn_id\":\"turn-1\",\"group_id\":\"g_1\",\"actor_id\":\"web-1\",\"event_ids\":[\"e_1\"],\"latest_event_id\":\"e_1\",\"latest_ts\":\"now\",\"messages\":[],\"coalesced_text\":\"hello\",\"system_prompt\":\"system\",\"delivery\":{\"mode\":\"recovery_no_cursor_mutation\",\"cursor_committed\":true,\"web_model_mode\":\"image_compat\"}}}}\n",
1037        ]);
1038        let client = CCCCClient::new(endpoint);
1039
1040        let snapshot = client
1041            .terminal_snapshot(
1042                "g_1",
1043                "web-1",
1044                &TerminalSnapshotOptions {
1045                    limit_bytes: Some(4096),
1046                    by: Some("user".into()),
1047                },
1048            )
1049            .expect("terminal snapshot");
1050        assert_eq!(snapshot.end_cursor, 7);
1051        let preference = client
1052            .web_model_delivery_preferences_get("g_1", "web-1")
1053            .expect("preference get");
1054        assert_eq!(
1055            preference.preference.mode,
1056            WebModelDeliveryMode::ImageCompat
1057        );
1058        client
1059            .web_model_delivery_preferences_update(
1060                "g_1",
1061                "web-1",
1062                WebModelDeliveryMode::ImageCompat,
1063                "user",
1064            )
1065            .expect("preference update");
1066        let recovered = client
1067            .web_model_runtime_recover_turn("g_1", "web-1", &["e_1".into()])
1068            .expect("recover turn");
1069        assert_eq!(recovered.status, "recovered");
1070
1071        let requests: Vec<Value> = server
1072            .join()
1073            .expect("server thread")
1074            .iter()
1075            .map(|request| serde_json::from_str(request).expect("request JSON"))
1076            .collect();
1077        assert_eq!(
1078            requests
1079                .iter()
1080                .map(|request| request["op"].as_str().expect("op"))
1081                .collect::<Vec<_>>(),
1082            vec![
1083                "terminal_snapshot",
1084                "web_model_delivery_preferences_get",
1085                "web_model_delivery_preferences_update",
1086                "web_model_runtime_recover_turn",
1087            ]
1088        );
1089        assert_eq!(requests[0]["args"]["limit_bytes"], 4096);
1090        assert_eq!(requests[2]["args"]["mode"], "image_compat");
1091        assert_eq!(requests[3]["args"]["event_ids"], json!(["e_1"]));
1092    }
1093
1094    #[test]
1095    fn term_resize_prefers_the_standard_op_and_falls_back_for_unknown_op() {
1096        let (endpoint, server) = server_sequence(vec![
1097            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
1098            "{\"v\":1,\"ok\":true,\"result\":{\"resized\":true,\"cols\":120,\"rows\":40}}\n",
1099        ]);
1100        let resized = CCCCClient::new(endpoint)
1101            .term_resize("g_1", "a_1", 120, 40)
1102            .expect("resize");
1103        assert_eq!(resized.group_id, "g_1");
1104        assert_eq!(resized.actor_id, "a_1");
1105        assert_eq!(resized.cols, 120);
1106        let requests = server.join().expect("server thread");
1107        let operations: Vec<String> = requests
1108            .iter()
1109            .map(|request| {
1110                serde_json::from_str::<Value>(request).expect("request JSON")["op"]
1111                    .as_str()
1112                    .expect("op")
1113                    .to_owned()
1114            })
1115            .collect();
1116        assert_eq!(operations, vec!["term_resize", "terminal_resize"]);
1117    }
1118
1119    #[test]
1120    fn compatibility_probe_accepts_the_bounded_resize_alias() {
1121        let (endpoint, server) = server_sequence(vec![
1122            "{\"v\":1,\"ok\":true,\"result\":{\"version\":\"test\",\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
1123            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
1124            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"invalid_request\",\"message\":\"missing args\",\"details\":{}}}\n",
1125        ]);
1126        let requirements = CompatibilityRequirements {
1127            minimum_ipc_version: 1,
1128            operations: vec!["term_resize"],
1129            ..Default::default()
1130        };
1131        CCCCClient::new(endpoint)
1132            .assert_compatible(&requirements)
1133            .expect("legacy alias is usable through the SDK");
1134        let operations: Vec<String> = server
1135            .join()
1136            .expect("server thread")
1137            .iter()
1138            .map(|request| {
1139                serde_json::from_str::<Value>(request).expect("request JSON")["op"]
1140                    .as_str()
1141                    .expect("op")
1142                    .to_owned()
1143            })
1144            .collect();
1145        assert_eq!(operations, vec!["ping", "term_resize", "terminal_resize"]);
1146    }
1147
1148    #[test]
1149    fn unknown_or_side_effectful_probes_fail_without_dispatch() {
1150        for op in [
1151            "connect_direct_configure",
1152            "membership_logout",
1153            "future_mutation",
1154        ] {
1155            let (endpoint, server) = server_sequence(vec![
1156                "{\"v\":1,\"ok\":true,\"result\":{\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
1157            ]);
1158            let requirements = CompatibilityRequirements {
1159                operations: vec![op],
1160                ..Default::default()
1161            };
1162            let error = CCCCClient::new(endpoint)
1163                .assert_compatible(&requirements)
1164                .unwrap_err();
1165            assert!(matches!(error, Error::Incompatible(_)));
1166            assert!(error.to_string().contains("cannot safely verify"));
1167            assert_eq!(server.join().unwrap().len(), 1);
1168        }
1169    }
1170
1171    #[test]
1172    fn connect_and_checked_context_keep_identity_and_conflict_tokens() {
1173        let (endpoint, server) = server_sequence(vec![
1174            "{\"v\":1,\"ok\":true,\"result\":{\"accepted\":true}}\n",
1175            "{\"v\":1,\"ok\":false,\"error\":{\"code\":\"version_conflict\",\"message\":\"changed\",\"details\":{}}}\n",
1176        ]);
1177        let client = CCCCClient::new(endpoint);
1178        let mut options = crate::ConnectSendOptions {
1179            group_id: "local".into(),
1180            instance_id: "peer".into(),
1181            target_group_id: "remote".into(),
1182            client_id: "key".into(),
1183            text: "hello".into(),
1184            message_mode: MessageMode::Mail,
1185            by: None,
1186            to: None,
1187            format: None,
1188            insight: None,
1189            attachments: None,
1190        };
1191        client.connect_send(&options).unwrap();
1192        let error = client
1193            .context_sync_checked("local", "user", vec![], Some("version"), false)
1194            .unwrap_err();
1195        assert!(matches!(error, Error::Daemon(ref e) if e.code == "version_conflict"));
1196        for key in ["".to_owned(), "界".repeat(43)] {
1197            options.client_id = key;
1198            assert!(matches!(
1199                client.connect_send(&options),
1200                Err(Error::InvalidArgument(_))
1201            ));
1202        }
1203        let calls: Vec<Value> = server
1204            .join()
1205            .unwrap()
1206            .iter()
1207            .map(|line| serde_json::from_str(line).unwrap())
1208            .collect();
1209        assert_eq!(calls[0]["args"]["instance_id"], "peer");
1210        assert_eq!(calls[0]["args"]["target_group_id"], "remote");
1211        assert_eq!(calls[0]["args"]["client_id"], "key");
1212        assert_eq!(calls[0]["args"]["message_mode"], "mail");
1213        assert_eq!(calls[1]["args"]["if_version"], "version");
1214    }
1215
1216    #[test]
1217    fn rediscovers_endpoint_after_connect_failure_before_exchange() {
1218        let home = temp_home("rediscover");
1219        let stale = TcpListener::bind("127.0.0.1:0").expect("bind stale port");
1220        let stale_port = stale.local_addr().expect("stale address").port();
1221        drop(stale);
1222        write_tcp_descriptor(&home, stale_port);
1223        let client = CCCCClient::discover_in(Some(&home)).expect("discover stale endpoint");
1224
1225        let listener = TcpListener::bind("127.0.0.1:0").expect("bind live server");
1226        let live_port = listener.local_addr().expect("live address").port();
1227        write_tcp_descriptor(&home, live_port);
1228        let server = thread::spawn(move || {
1229            let (mut stream, _) = listener.accept().expect("accept client");
1230            let mut request = String::new();
1231            BufReader::new(&mut stream)
1232                .read_line(&mut request)
1233                .expect("read request");
1234            stream
1235                .write_all(b"{\"v\":1,\"ok\":true,\"result\":{\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n")
1236                .expect("write response");
1237        });
1238
1239        assert_eq!(client.ping().expect("ping").ipc_v, 1);
1240        assert_eq!(
1241            client.current_endpoint(),
1242            DaemonEndpoint::Tcp {
1243                host: "127.0.0.1".into(),
1244                port: live_port,
1245            }
1246        );
1247        server.join().expect("server thread");
1248        fs::remove_dir_all(home).expect("cleanup");
1249    }
1250
1251    #[test]
1252    fn exchange_failure_is_reported_as_outcome_unknown_without_replay() {
1253        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
1254        let address = listener.local_addr().expect("local address");
1255        let server = thread::spawn(move || {
1256            let (mut stream, _) = listener.accept().expect("accept client");
1257            let mut request = String::new();
1258            BufReader::new(&mut stream)
1259                .read_line(&mut request)
1260                .expect("read request");
1261        });
1262        let client = CCCCClient::new(DaemonEndpoint::Tcp {
1263            host: "127.0.0.1".into(),
1264            port: address.port(),
1265        });
1266        assert!(matches!(
1267            client.call("non_idempotent_write", Map::new()),
1268            Err(Error::OutcomeUnknown { ref op, .. }) if op == "non_idempotent_write"
1269        ));
1270        server.join().expect("server thread");
1271    }
1272}