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            | "events_stream"
670            | "connect_catalog"
671            | "connect_send"
672            | "connect_send_files"
673    )
674}
675
676fn finish_attempt_error(op: &str, error: CallAttemptError) -> Error {
677    match error {
678        CallAttemptError::Connect(error) => error,
679        CallAttemptError::Exchange(error) => Error::OutcomeUnknown {
680            op: op.to_owned(),
681            message: error.to_string(),
682        },
683    }
684}
685
686fn object<const N: usize>(entries: [(&str, Value); N]) -> Map<String, Value> {
687    entries
688        .into_iter()
689        .map(|(key, value)| (key.to_owned(), value))
690        .collect()
691}
692
693fn exchange<S: Read + Write>(stream: &mut S, request: &[u8]) -> Result<DaemonResponse> {
694    stream.write_all(request)?;
695    stream.flush()?;
696
697    let mut reader = BufReader::new(stream);
698    let mut response = Vec::new();
699    let read = reader
700        .by_ref()
701        .take((MAX_RESPONSE_BYTES + 1) as u64)
702        .read_until(b'\n', &mut response)?;
703    if read == 0 {
704        return Err(Error::EmptyResponse);
705    }
706    if response.len() > MAX_RESPONSE_BYTES {
707        return Err(Error::ResponseTooLarge(MAX_RESPONSE_BYTES));
708    }
709    Ok(serde_json::from_slice(&response)?)
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use std::env;
716    use std::fs;
717    use std::net::TcpListener;
718    use std::thread;
719    use std::time::{SystemTime, UNIX_EPOCH};
720
721    fn server_once(response: &'static str) -> (DaemonEndpoint, thread::JoinHandle<String>) {
722        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
723        let address = listener.local_addr().expect("local address");
724        let handle = thread::spawn(move || {
725            let (mut stream, _) = listener.accept().expect("accept client");
726            let mut request = String::new();
727            BufReader::new(&mut stream)
728                .read_line(&mut request)
729                .expect("read request");
730            stream
731                .write_all(response.as_bytes())
732                .expect("write response");
733            request
734        });
735        (
736            DaemonEndpoint::Tcp {
737                host: "127.0.0.1".into(),
738                port: address.port(),
739            },
740            handle,
741        )
742    }
743
744    fn server_sequence(
745        responses: Vec<&'static str>,
746    ) -> (DaemonEndpoint, thread::JoinHandle<Vec<String>>) {
747        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
748        let address = listener.local_addr().expect("local address");
749        let handle = thread::spawn(move || {
750            responses
751                .into_iter()
752                .map(|response| {
753                    let (mut stream, _) = listener.accept().expect("accept client");
754                    let mut request = String::new();
755                    BufReader::new(&mut stream)
756                        .read_line(&mut request)
757                        .expect("read request");
758                    stream
759                        .write_all(response.as_bytes())
760                        .expect("write response");
761                    request
762                })
763                .collect()
764        });
765        (
766            DaemonEndpoint::Tcp {
767                host: "127.0.0.1".into(),
768                port: address.port(),
769            },
770            handle,
771        )
772    }
773
774    fn temp_home(label: &str) -> PathBuf {
775        let nonce = SystemTime::now()
776            .duration_since(UNIX_EPOCH)
777            .expect("clock")
778            .as_nanos();
779        env::temp_dir().join(format!(
780            "cccc-sdk-client-{label}-{}-{nonce}",
781            std::process::id()
782        ))
783    }
784
785    fn write_tcp_descriptor(home: &Path, port: u16) {
786        let daemon = home.join("daemon");
787        fs::create_dir_all(&daemon).expect("create daemon directory");
788        fs::write(
789            daemon.join("ccccd.addr.json"),
790            format!(r#"{{"v":1,"transport":"tcp","host":"127.0.0.1","port":{port}}}"#),
791        )
792        .expect("write descriptor");
793    }
794
795    #[test]
796    fn sends_a_valid_ping_envelope() {
797        let (endpoint, server) = server_once(
798            "{\"v\":1,\"ok\":true,\"result\":{\"version\":\"0.4.33\",\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
799        );
800        let ping = CCCCClient::new(endpoint).ping().expect("ping");
801        assert_eq!(ping.version, "0.4.33");
802        assert_eq!(ping.ipc_v, 1);
803
804        let request: Value =
805            serde_json::from_str(&server.join().expect("server thread")).expect("request JSON");
806        assert_eq!(request, json!({"v": 1, "op": "ping", "args": {}}));
807    }
808
809    #[test]
810    fn preserves_structured_daemon_errors() {
811        let (endpoint, server) = server_once(
812            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
813        );
814        let error = CCCCClient::new(endpoint)
815            .call("future_op", Map::new())
816            .expect_err("daemon error");
817        assert!(matches!(
818            error,
819            Error::Daemon(crate::DaemonError { ref code, .. }) if code == "unknown_op"
820        ));
821        server.join().expect("server thread");
822    }
823
824    #[test]
825    fn maps_current_message_delivery_and_inbox_operations() {
826        let response = "{\"v\":1,\"ok\":true,\"result\":{}}\n";
827        let (endpoint, server) = server_sequence(vec![response; 7]);
828        let client = CCCCClient::new(endpoint);
829
830        client
831            .send("g_1", "FYI", MessageMode::Mail, "user")
832            .expect("send Mail");
833        client
834            .reply_with_mode(
835                "g_1",
836                "e_request",
837                "quiet follow-up",
838                ReplyMessageMode::Mail,
839                "peer-1",
840            )
841            .expect("reply with Mail");
842        client
843            .reply_request_cancel("g_1", "e_request", "user")
844            .expect("cancel reply request");
845        client
846            .message_deliver("g_1", "e_mail", &["peer-1"], "user", true)
847            .expect("deliver existing message");
848        client
849            .inbox_peek("g_1", "peer-1", "user", Some(5))
850            .expect("peek Inbox");
851        client
852            .inbox_read("g_1", "peer-1", "peer-1", Some(3))
853            .expect("read Inbox");
854        client
855            .message_history(
856                "g_1",
857                "peer-1",
858                "user",
859                MessageHistoryMode::Send,
860                Some("decision"),
861                Some("e_before"),
862                Some(7),
863            )
864            .expect("read message history");
865
866        let requests: Vec<Value> = server
867            .join()
868            .expect("server thread")
869            .iter()
870            .map(|request| serde_json::from_str(request).expect("request JSON"))
871            .collect();
872        assert_eq!(requests[0]["args"]["message_mode"], "mail");
873        assert_eq!(requests[1]["op"], "reply");
874        assert_eq!(requests[1]["args"]["message_mode"], "mail");
875        assert_eq!(requests[2]["op"], "reply_request_cancel");
876        assert_eq!(requests[3]["args"]["actor_ids"], json!(["peer-1"]));
877        assert_eq!(requests[3]["args"]["force_ambiguous"], true);
878        assert_eq!(requests[4]["op"], "inbox_peek");
879        assert_eq!(requests[5]["op"], "inbox_read");
880        assert_eq!(requests[6]["op"], "message_history");
881        assert_eq!(requests[6]["args"]["mode"], "send");
882        assert_eq!(requests[6]["args"]["query"], "decision");
883        assert_eq!(requests[6]["args"]["before_event_id"], "e_before");
884        assert_eq!(requests[6]["args"]["limit"], 7);
885        assert!(matches!(
886            client.message_deliver("g_1", "e_mail", &[], "user", false),
887            Err(Error::InvalidArgument(_))
888        ));
889    }
890
891    #[test]
892    fn context_get_uses_the_current_projection_contract() {
893        let (endpoint, server) = server_once("{\"v\":1,\"ok\":true,\"result\":{}}\n");
894        CCCCClient::new(endpoint)
895            .context_get_with_detail("g_1", ContextDetail::Summary)
896            .expect("summary context");
897        let request: Value =
898            serde_json::from_str(&server.join().expect("server thread")).expect("request JSON");
899        assert_eq!(
900            request,
901            json!({"v": 1, "op": "context_get", "args": {"group_id": "g_1", "detail": "summary"}})
902        );
903    }
904
905    #[test]
906    fn never_probes_destructive_or_streaming_operations() {
907        assert!(!operation_probe_is_safe("shutdown"));
908        assert!(!operation_probe_is_safe("group_create"));
909        assert!(!operation_probe_is_safe("remote_access_start"));
910        assert!(!operation_probe_is_safe("term_attach"));
911        assert!(operation_probe_is_safe("group_show"));
912    }
913
914    #[test]
915    fn call_raw_rejects_an_unsupported_response_version() {
916        let (endpoint, server) = server_once("{\"v\":2,\"ok\":true,\"result\":{}}\n");
917        let error = CCCCClient::new(endpoint)
918            .call_raw("ping", Map::new())
919            .expect_err("unsupported version");
920        assert!(matches!(error, Error::UnsupportedIpcVersion(2)));
921        server.join().expect("server thread");
922    }
923
924    #[test]
925    fn maps_current_terminal_and_web_model_operations() {
926        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";
927        let (endpoint, server) = server_sequence(vec![
928            "{\"v\":1,\"ok\":true,\"result\":{\"data\":\"screen\",\"start_cursor\":1,\"end_cursor\":7}}\n",
929            preference,
930            preference,
931            "{\"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",
932        ]);
933        let client = CCCCClient::new(endpoint);
934
935        let snapshot = client
936            .terminal_snapshot(
937                "g_1",
938                "web-1",
939                &TerminalSnapshotOptions {
940                    limit_bytes: Some(4096),
941                    by: Some("user".into()),
942                },
943            )
944            .expect("terminal snapshot");
945        assert_eq!(snapshot.end_cursor, 7);
946        let preference = client
947            .web_model_delivery_preferences_get("g_1", "web-1")
948            .expect("preference get");
949        assert_eq!(
950            preference.preference.mode,
951            WebModelDeliveryMode::ImageCompat
952        );
953        client
954            .web_model_delivery_preferences_update(
955                "g_1",
956                "web-1",
957                WebModelDeliveryMode::ImageCompat,
958                "user",
959            )
960            .expect("preference update");
961        let recovered = client
962            .web_model_runtime_recover_turn("g_1", "web-1", &["e_1".into()])
963            .expect("recover turn");
964        assert_eq!(recovered.status, "recovered");
965
966        let requests: Vec<Value> = server
967            .join()
968            .expect("server thread")
969            .iter()
970            .map(|request| serde_json::from_str(request).expect("request JSON"))
971            .collect();
972        assert_eq!(
973            requests
974                .iter()
975                .map(|request| request["op"].as_str().expect("op"))
976                .collect::<Vec<_>>(),
977            vec![
978                "terminal_snapshot",
979                "web_model_delivery_preferences_get",
980                "web_model_delivery_preferences_update",
981                "web_model_runtime_recover_turn",
982            ]
983        );
984        assert_eq!(requests[0]["args"]["limit_bytes"], 4096);
985        assert_eq!(requests[2]["args"]["mode"], "image_compat");
986        assert_eq!(requests[3]["args"]["event_ids"], json!(["e_1"]));
987    }
988
989    #[test]
990    fn term_resize_prefers_the_standard_op_and_falls_back_for_unknown_op() {
991        let (endpoint, server) = server_sequence(vec![
992            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
993            "{\"v\":1,\"ok\":true,\"result\":{\"resized\":true,\"cols\":120,\"rows\":40}}\n",
994        ]);
995        let resized = CCCCClient::new(endpoint)
996            .term_resize("g_1", "a_1", 120, 40)
997            .expect("resize");
998        assert_eq!(resized.group_id, "g_1");
999        assert_eq!(resized.actor_id, "a_1");
1000        assert_eq!(resized.cols, 120);
1001        let requests = server.join().expect("server thread");
1002        let operations: Vec<String> = requests
1003            .iter()
1004            .map(|request| {
1005                serde_json::from_str::<Value>(request).expect("request JSON")["op"]
1006                    .as_str()
1007                    .expect("op")
1008                    .to_owned()
1009            })
1010            .collect();
1011        assert_eq!(operations, vec!["term_resize", "terminal_resize"]);
1012    }
1013
1014    #[test]
1015    fn compatibility_probe_accepts_the_bounded_resize_alias() {
1016        let (endpoint, server) = server_sequence(vec![
1017            "{\"v\":1,\"ok\":true,\"result\":{\"version\":\"test\",\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
1018            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
1019            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"invalid_request\",\"message\":\"missing args\",\"details\":{}}}\n",
1020        ]);
1021        let requirements = CompatibilityRequirements {
1022            minimum_ipc_version: 1,
1023            operations: vec!["term_resize"],
1024            ..Default::default()
1025        };
1026        CCCCClient::new(endpoint)
1027            .assert_compatible(&requirements)
1028            .expect("legacy alias is usable through the SDK");
1029        let operations: Vec<String> = server
1030            .join()
1031            .expect("server thread")
1032            .iter()
1033            .map(|request| {
1034                serde_json::from_str::<Value>(request).expect("request JSON")["op"]
1035                    .as_str()
1036                    .expect("op")
1037                    .to_owned()
1038            })
1039            .collect();
1040        assert_eq!(operations, vec!["ping", "term_resize", "terminal_resize"]);
1041    }
1042
1043    #[test]
1044    fn unknown_or_side_effectful_probes_fail_without_dispatch() {
1045        for op in [
1046            "connect_direct_configure",
1047            "membership_logout",
1048            "future_mutation",
1049        ] {
1050            let (endpoint, server) = server_sequence(vec![
1051                "{\"v\":1,\"ok\":true,\"result\":{\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
1052            ]);
1053            let requirements = CompatibilityRequirements {
1054                operations: vec![op],
1055                ..Default::default()
1056            };
1057            let error = CCCCClient::new(endpoint)
1058                .assert_compatible(&requirements)
1059                .unwrap_err();
1060            assert!(matches!(error, Error::Incompatible(_)));
1061            assert!(error.to_string().contains("cannot safely verify"));
1062            assert_eq!(server.join().unwrap().len(), 1);
1063        }
1064    }
1065
1066    #[test]
1067    fn connect_and_checked_context_keep_identity_and_conflict_tokens() {
1068        let (endpoint, server) = server_sequence(vec![
1069            "{\"v\":1,\"ok\":true,\"result\":{\"accepted\":true}}\n",
1070            "{\"v\":1,\"ok\":false,\"error\":{\"code\":\"version_conflict\",\"message\":\"changed\",\"details\":{}}}\n",
1071        ]);
1072        let client = CCCCClient::new(endpoint);
1073        let mut options = crate::ConnectSendOptions {
1074            group_id: "local".into(),
1075            instance_id: "peer".into(),
1076            target_group_id: "remote".into(),
1077            client_id: "key".into(),
1078            text: "hello".into(),
1079            message_mode: MessageMode::Mail,
1080            by: None,
1081            to: None,
1082            format: None,
1083            insight: None,
1084            attachments: None,
1085        };
1086        client.connect_send(&options).unwrap();
1087        let error = client
1088            .context_sync_checked("local", "user", vec![], Some("version"), false)
1089            .unwrap_err();
1090        assert!(matches!(error, Error::Daemon(ref e) if e.code == "version_conflict"));
1091        for key in ["".to_owned(), "界".repeat(43)] {
1092            options.client_id = key;
1093            assert!(matches!(
1094                client.connect_send(&options),
1095                Err(Error::InvalidArgument(_))
1096            ));
1097        }
1098        let calls: Vec<Value> = server
1099            .join()
1100            .unwrap()
1101            .iter()
1102            .map(|line| serde_json::from_str(line).unwrap())
1103            .collect();
1104        assert_eq!(calls[0]["args"]["instance_id"], "peer");
1105        assert_eq!(calls[0]["args"]["target_group_id"], "remote");
1106        assert_eq!(calls[0]["args"]["client_id"], "key");
1107        assert_eq!(calls[0]["args"]["message_mode"], "mail");
1108        assert_eq!(calls[1]["args"]["if_version"], "version");
1109    }
1110
1111    #[test]
1112    fn rediscovers_endpoint_after_connect_failure_before_exchange() {
1113        let home = temp_home("rediscover");
1114        let stale = TcpListener::bind("127.0.0.1:0").expect("bind stale port");
1115        let stale_port = stale.local_addr().expect("stale address").port();
1116        drop(stale);
1117        write_tcp_descriptor(&home, stale_port);
1118        let client = CCCCClient::discover_in(Some(&home)).expect("discover stale endpoint");
1119
1120        let listener = TcpListener::bind("127.0.0.1:0").expect("bind live server");
1121        let live_port = listener.local_addr().expect("live address").port();
1122        write_tcp_descriptor(&home, live_port);
1123        let server = thread::spawn(move || {
1124            let (mut stream, _) = listener.accept().expect("accept client");
1125            let mut request = String::new();
1126            BufReader::new(&mut stream)
1127                .read_line(&mut request)
1128                .expect("read request");
1129            stream
1130                .write_all(b"{\"v\":1,\"ok\":true,\"result\":{\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n")
1131                .expect("write response");
1132        });
1133
1134        assert_eq!(client.ping().expect("ping").ipc_v, 1);
1135        assert_eq!(
1136            client.current_endpoint(),
1137            DaemonEndpoint::Tcp {
1138                host: "127.0.0.1".into(),
1139                port: live_port,
1140            }
1141        );
1142        server.join().expect("server thread");
1143        fs::remove_dir_all(home).expect("cleanup");
1144    }
1145
1146    #[test]
1147    fn exchange_failure_is_reported_as_outcome_unknown_without_replay() {
1148        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
1149        let address = listener.local_addr().expect("local address");
1150        let server = thread::spawn(move || {
1151            let (mut stream, _) = listener.accept().expect("accept client");
1152            let mut request = String::new();
1153            BufReader::new(&mut stream)
1154                .read_line(&mut request)
1155                .expect("read request");
1156        });
1157        let client = CCCCClient::new(DaemonEndpoint::Tcp {
1158            host: "127.0.0.1".into(),
1159            port: address.port(),
1160        });
1161        assert!(matches!(
1162            client.call("non_idempotent_write", Map::new()),
1163            Err(Error::OutcomeUnknown { ref op, .. }) if op == "non_idempotent_write"
1164        ));
1165        server.join().expect("server thread");
1166    }
1167}