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    InboxSummaryResult, MessageHistoryMode, MessageMode, PingResult, ReplyMessageMode, Result,
14    TerminalHistoryOptions, TerminalHistoryResult, TerminalResizeResult, TerminalSinceOptions,
15    TerminalSinceResult, 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    /// Summarize current awaiting Mail without advancing cursors.
340    ///
341    /// The inclusive RFC3339 cutoff is an age threshold, not a historical
342    /// snapshot. The daemon validates the cutoff and caller permissions.
343    pub fn inbox_summary(
344        &self,
345        group_id: &str,
346        cutoff: &str,
347        by: &str,
348    ) -> Result<InboxSummaryResult> {
349        self.call_typed(
350            "inbox_summary",
351            object([
352                ("group_id", json!(group_id)),
353                ("cutoff", json!(cutoff)),
354                ("by", json!(by)),
355            ]),
356        )
357    }
358
359    /// Inspect actor-visible chat history without consuming Mail.
360    #[allow(clippy::too_many_arguments)]
361    pub fn message_history(
362        &self,
363        group_id: &str,
364        actor_id: &str,
365        by: &str,
366        mode: MessageHistoryMode,
367        query: Option<&str>,
368        before_event_id: Option<&str>,
369        limit: Option<u32>,
370    ) -> Result<Map<String, Value>> {
371        let mut args = object([
372            ("group_id", json!(group_id)),
373            ("actor_id", json!(actor_id)),
374            ("by", json!(by)),
375            ("mode", json!(mode.as_str())),
376        ]);
377        if let Some(query) = query.filter(|value| !value.is_empty()) {
378            args.insert("query".into(), json!(query));
379        }
380        if let Some(before_event_id) = before_event_id.filter(|value| !value.is_empty()) {
381            args.insert("before_event_id".into(), json!(before_event_id));
382        }
383        if let Some(limit) = limit {
384            args.insert("limit".into(), json!(limit));
385        }
386        self.call("message_history", args)
387    }
388
389    /// Read one visible event and its rendered delivery text without consuming
390    /// Mail. Older daemons silently ignore `event_id`; their list response is
391    /// rejected instead of being mistaken for an exact read.
392    pub fn message_history_exact(
393        &self,
394        group_id: &str,
395        actor_id: &str,
396        event_id: &str,
397        by: &str,
398    ) -> Result<Map<String, Value>> {
399        let event_id = event_id.trim();
400        if event_id.is_empty() {
401            return Err(Error::InvalidArgument("event_id must not be empty".into()));
402        }
403        let result = self.call(
404            "message_history",
405            object([
406                ("group_id", json!(group_id)),
407                ("actor_id", json!(actor_id)),
408                ("event_id", json!(event_id)),
409                ("by", json!(by)),
410            ]),
411        )?;
412        let exact_event = result
413            .get("messages")
414            .and_then(Value::as_array)
415            .filter(|messages| messages.len() == 1)
416            .and_then(|messages| messages[0].get("id"))
417            .and_then(Value::as_str)
418            == Some(event_id);
419        let delivery_text_valid = result
420            .get("delivery_text")
421            .is_some_and(|value| value.is_null() || value.is_string());
422        if !exact_event
423            || result.get("has_more") != Some(&Value::Bool(false))
424            || !delivery_text_valid
425        {
426            return Err(Error::Incompatible(
427                "daemon did not return an exact message history result; exact reads require CCCC 0.4.46 or later".into(),
428            ));
429        }
430        Ok(result)
431    }
432
433    pub fn context_get(&self, group_id: &str) -> Result<Map<String, Value>> {
434        self.context_get_with_detail(group_id, ContextDetail::Full)
435    }
436
437    pub fn context_get_with_detail(
438        &self,
439        group_id: &str,
440        detail: ContextDetail,
441    ) -> Result<Map<String, Value>> {
442        self.call(
443            "context_get",
444            object([
445                ("group_id", json!(group_id)),
446                ("detail", json!(detail.as_str())),
447            ]),
448        )
449    }
450
451    pub fn context_sync(
452        &self,
453        group_id: &str,
454        by: &str,
455        operations: Vec<Value>,
456    ) -> Result<Map<String, Value>> {
457        self.context_sync_checked(group_id, by, operations, None, false)
458    }
459
460    pub fn context_sync_checked(
461        &self,
462        group_id: &str,
463        by: &str,
464        operations: Vec<Value>,
465        if_version: Option<&str>,
466        dry_run: bool,
467    ) -> Result<Map<String, Value>> {
468        let mut args = object([
469            ("group_id", json!(group_id)),
470            ("by", json!(by)),
471            ("ops", Value::Array(operations)),
472            ("dry_run", json!(dry_run)),
473        ]);
474        if let Some(version) = if_version {
475            args.insert("if_version".into(), json!(version));
476        }
477        self.call("context_sync", args)
478    }
479
480    pub fn terminal_history(
481        &self,
482        group_id: &str,
483        actor_id: &str,
484        options: &TerminalHistoryOptions,
485    ) -> Result<TerminalHistoryResult> {
486        let mut args = object([("group_id", json!(group_id)), ("actor_id", json!(actor_id))]);
487        if let Some(render_before) = options.render_before {
488            args.insert("render_before".into(), json!(render_before));
489        }
490        if let Some(before) = options.before {
491            args.insert("before".into(), json!(before));
492        }
493        if let Some(limit_bytes) = options.limit_bytes {
494            args.insert("limit_bytes".into(), json!(limit_bytes));
495        }
496        if let Some(strip_ansi) = options.strip_ansi {
497            args.insert("strip_ansi".into(), json!(strip_ansi));
498        }
499        if let Some(compact) = options.compact {
500            args.insert("compact".into(), json!(compact));
501        }
502        if let Some(by) = options.by.as_deref() {
503            args.insert("by".into(), json!(by));
504        }
505        self.call_typed("terminal_history", args)
506    }
507
508    pub fn terminal_since(
509        &self,
510        group_id: &str,
511        actor_id: &str,
512        after: u64,
513        options: &TerminalSinceOptions,
514    ) -> Result<TerminalSinceResult> {
515        let mut args = object([
516            ("group_id", json!(group_id)),
517            ("actor_id", json!(actor_id)),
518            ("after", json!(after)),
519        ]);
520        if let Some(limit_bytes) = options.limit_bytes {
521            args.insert("limit_bytes".into(), json!(limit_bytes));
522        }
523        if let Some(by) = options.by.as_deref() {
524            args.insert("by".into(), json!(by));
525        }
526        self.call_typed("terminal_since", args)
527    }
528
529    pub fn terminal_snapshot(
530        &self,
531        group_id: &str,
532        actor_id: &str,
533        options: &TerminalSnapshotOptions,
534    ) -> Result<TerminalSnapshotResult> {
535        let mut args = object([("group_id", json!(group_id)), ("actor_id", json!(actor_id))]);
536        if let Some(limit_bytes) = options.limit_bytes {
537            args.insert("limit_bytes".into(), json!(limit_bytes));
538        }
539        if let Some(by) = options.by.as_deref() {
540            args.insert("by".into(), json!(by));
541        }
542        self.call_typed("terminal_snapshot", args)
543    }
544
545    /// Resize a PTY using the normative op, with a compatibility fallback for
546    /// Rust daemon builds that temporarily exposed `terminal_resize` instead.
547    pub fn term_resize(
548        &self,
549        group_id: &str,
550        actor_id: &str,
551        cols: u32,
552        rows: u32,
553    ) -> Result<TerminalResizeResult> {
554        let args = object([
555            ("group_id", json!(group_id)),
556            ("actor_id", json!(actor_id)),
557            ("cols", json!(cols)),
558            ("rows", json!(rows)),
559        ]);
560        match self.call_typed("term_resize", args.clone()) {
561            Err(Error::Daemon(error)) if error.code == "unknown_op" => {
562                let legacy = self.call("terminal_resize", args)?;
563                let resolved_cols = legacy
564                    .get("cols")
565                    .and_then(Value::as_u64)
566                    .and_then(|value| u32::try_from(value).ok())
567                    .unwrap_or(cols);
568                let resolved_rows = legacy
569                    .get("rows")
570                    .and_then(Value::as_u64)
571                    .and_then(|value| u32::try_from(value).ok())
572                    .unwrap_or(rows);
573                Ok(TerminalResizeResult {
574                    group_id: group_id.to_owned(),
575                    actor_id: actor_id.to_owned(),
576                    cols: resolved_cols,
577                    rows: resolved_rows,
578                })
579            }
580            result => result,
581        }
582    }
583
584    /// Read delivery preferences for a ChatGPT browser Actor.
585    pub fn web_model_delivery_preferences_get(
586        &self,
587        group_id: &str,
588        actor_id: &str,
589    ) -> Result<WebModelDeliveryPreferencesResult> {
590        self.call_typed(
591            "web_model_delivery_preferences_get",
592            object([("group_id", json!(group_id)), ("actor_id", json!(actor_id))]),
593        )
594    }
595
596    /// Update delivery preferences for a ChatGPT browser Actor.
597    pub fn web_model_delivery_preferences_update(
598        &self,
599        group_id: &str,
600        actor_id: &str,
601        mode: WebModelDeliveryMode,
602        by: &str,
603    ) -> Result<WebModelDeliveryPreferencesResult> {
604        self.call_typed(
605            "web_model_delivery_preferences_update",
606            object([
607                ("group_id", json!(group_id)),
608                ("actor_id", json!(actor_id)),
609                ("mode", json!(mode)),
610                ("by", json!(by)),
611            ]),
612        )
613    }
614
615    /// Recover a ChatGPT browser turn. Grok uses its own event path.
616    pub fn web_model_runtime_recover_turn(
617        &self,
618        group_id: &str,
619        actor_id: &str,
620        event_ids: &[String],
621    ) -> Result<WebModelRuntimeRecoverTurnResult> {
622        if event_ids.is_empty() || event_ids.iter().any(|event_id| event_id.trim().is_empty()) {
623            return Err(Error::InvalidArgument(
624                "event_ids must contain at least one non-empty event id".into(),
625            ));
626        }
627        self.call_typed(
628            "web_model_runtime_recover_turn",
629            object([
630                ("group_id", json!(group_id)),
631                ("actor_id", json!(actor_id)),
632                ("event_ids", json!(event_ids)),
633            ]),
634        )
635    }
636
637    /// Validate protocol, advertised capabilities, and actual op recognition.
638    pub fn assert_compatible(
639        &self,
640        requirements: &CompatibilityRequirements<'_>,
641    ) -> Result<PingResult> {
642        let ping = self.ping()?;
643        let minimum = requirements.minimum_ipc_version.max(1);
644        if ping.ipc_v < minimum {
645            return Err(Error::Incompatible(format!(
646                "daemon ipc_v={} is below required ipc_v={minimum}",
647                ping.ipc_v
648            )));
649        }
650        for (name, required) in &requirements.capabilities {
651            if *required && ping.capabilities.get(*name).and_then(Value::as_bool) != Some(true) {
652                return Err(Error::Incompatible(format!(
653                    "daemon capability {name}=true is required"
654                )));
655            }
656        }
657        for operation in &requirements.operations {
658            if *operation == "ping" {
659                continue;
660            }
661            let advertised = ping.capabilities.get(*operation).and_then(Value::as_bool);
662            if advertised == Some(false) {
663                return Err(Error::Incompatible(format!(
664                    "daemon does not support operation {operation}"
665                )));
666            }
667            if !operation_probe_is_safe(operation) {
668                if advertised == Some(true) {
669                    continue;
670                }
671                return Err(Error::Incompatible(format!("cannot safely verify operation {operation}; no advertised capability or safe probe")));
672            }
673            match self.call(operation, Map::new()) {
674                Err(Error::Daemon(error)) if error.code == "unknown_op" => {
675                    if *operation == "term_resize" {
676                        match self.call("terminal_resize", Map::new()) {
677                            Err(Error::Daemon(alias_error)) if alias_error.code == "unknown_op" => {
678                            }
679                            Err(Error::Daemon(_)) | Ok(_) => continue,
680                            Err(error) => return Err(error),
681                        }
682                    }
683                    return Err(Error::Incompatible(format!(
684                        "daemon does not support operation {operation}"
685                    )));
686                }
687                Err(Error::Daemon(_)) | Ok(_) => {}
688                Err(error) => return Err(error),
689            }
690        }
691        Ok(ping)
692    }
693
694    fn call_typed<T: DeserializeOwned>(&self, op: &str, args: Map<String, Value>) -> Result<T> {
695        let result = self.call(op, args)?;
696        Ok(serde_json::from_value(Value::Object(result))?)
697    }
698}
699
700// Explicitly reviewed empty-argument probes; unknown operations must never run.
701fn operation_probe_is_safe(operation: &str) -> bool {
702    matches!(
703        operation,
704        "groups"
705            | "group_show"
706            | "group_preamble_get"
707            | "group_preamble_set"
708            | "group_preamble_reset"
709            | "send"
710            | "tracked_send"
711            | "send_files"
712            | "reply"
713            | "inbox_peek"
714            | "inbox_read"
715            | "inbox_summary"
716            | "context_get"
717            | "context_sync"
718            | "message_deliver"
719            | "message_history"
720            | "reply_request_cancel"
721            | "send_cross_group"
722            | "memory_search"
723            | "memory_get"
724            | "memory_write"
725            | "memory_profile_get"
726            | "memory_health"
727            | "actor_new_session"
728            | "group_reset"
729            | "group_copy_export_file"
730            | "terminal_history"
731            | "terminal_since"
732            | "terminal_snapshot"
733            | "term_resize"
734            | "web_model_delivery_preferences_get"
735            | "web_model_delivery_preferences_update"
736            | "web_model_runtime_recover_turn"
737            | "assistant_voice_document_library"
738            | "assistant_voice_document_library_update"
739            | "assistant_voice_document_delete"
740            | "events_stream"
741            | "connect_catalog"
742            | "connect_send"
743            | "connect_send_files"
744    )
745}
746
747fn finish_attempt_error(op: &str, error: CallAttemptError) -> Error {
748    match error {
749        CallAttemptError::Connect(error) => error,
750        CallAttemptError::Exchange(error) => Error::OutcomeUnknown {
751            op: op.to_owned(),
752            message: error.to_string(),
753        },
754    }
755}
756
757fn object<const N: usize>(entries: [(&str, Value); N]) -> Map<String, Value> {
758    entries
759        .into_iter()
760        .map(|(key, value)| (key.to_owned(), value))
761        .collect()
762}
763
764fn exchange<S: Read + Write>(stream: &mut S, request: &[u8]) -> Result<DaemonResponse> {
765    stream.write_all(request)?;
766    stream.flush()?;
767
768    let mut reader = BufReader::new(stream);
769    let mut response = Vec::new();
770    let read = reader
771        .by_ref()
772        .take((MAX_RESPONSE_BYTES + 1) as u64)
773        .read_until(b'\n', &mut response)?;
774    if read == 0 {
775        return Err(Error::EmptyResponse);
776    }
777    if response.len() > MAX_RESPONSE_BYTES {
778        return Err(Error::ResponseTooLarge(MAX_RESPONSE_BYTES));
779    }
780    Ok(serde_json::from_slice(&response)?)
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786    use std::env;
787    use std::fs;
788    use std::net::TcpListener;
789    use std::thread;
790    use std::time::{SystemTime, UNIX_EPOCH};
791
792    fn server_once(response: &'static str) -> (DaemonEndpoint, thread::JoinHandle<String>) {
793        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
794        let address = listener.local_addr().expect("local address");
795        let handle = thread::spawn(move || {
796            let (mut stream, _) = listener.accept().expect("accept client");
797            let mut request = String::new();
798            BufReader::new(&mut stream)
799                .read_line(&mut request)
800                .expect("read request");
801            stream
802                .write_all(response.as_bytes())
803                .expect("write response");
804            request
805        });
806        (
807            DaemonEndpoint::Tcp {
808                host: "127.0.0.1".into(),
809                port: address.port(),
810            },
811            handle,
812        )
813    }
814
815    fn server_sequence(
816        responses: Vec<&'static str>,
817    ) -> (DaemonEndpoint, thread::JoinHandle<Vec<String>>) {
818        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
819        let address = listener.local_addr().expect("local address");
820        let handle = thread::spawn(move || {
821            responses
822                .into_iter()
823                .map(|response| {
824                    let (mut stream, _) = listener.accept().expect("accept client");
825                    let mut request = String::new();
826                    BufReader::new(&mut stream)
827                        .read_line(&mut request)
828                        .expect("read request");
829                    stream
830                        .write_all(response.as_bytes())
831                        .expect("write response");
832                    request
833                })
834                .collect()
835        });
836        (
837            DaemonEndpoint::Tcp {
838                host: "127.0.0.1".into(),
839                port: address.port(),
840            },
841            handle,
842        )
843    }
844
845    fn temp_home(label: &str) -> PathBuf {
846        let nonce = SystemTime::now()
847            .duration_since(UNIX_EPOCH)
848            .expect("clock")
849            .as_nanos();
850        env::temp_dir().join(format!(
851            "cccc-sdk-client-{label}-{}-{nonce}",
852            std::process::id()
853        ))
854    }
855
856    fn write_tcp_descriptor(home: &Path, port: u16) {
857        let daemon = home.join("daemon");
858        fs::create_dir_all(&daemon).expect("create daemon directory");
859        fs::write(
860            daemon.join("ccccd.addr.json"),
861            format!(r#"{{"v":1,"transport":"tcp","host":"127.0.0.1","port":{port}}}"#),
862        )
863        .expect("write descriptor");
864    }
865
866    #[test]
867    fn sends_a_valid_ping_envelope() {
868        let (endpoint, server) = server_once(
869            "{\"v\":1,\"ok\":true,\"result\":{\"version\":\"0.4.33\",\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
870        );
871        let ping = CCCCClient::new(endpoint).ping().expect("ping");
872        assert_eq!(ping.version, "0.4.33");
873        assert_eq!(ping.ipc_v, 1);
874
875        let request: Value =
876            serde_json::from_str(&server.join().expect("server thread")).expect("request JSON");
877        assert_eq!(request, json!({"v": 1, "op": "ping", "args": {}}));
878    }
879
880    #[test]
881    fn voice_library_preserves_actions_and_empty_values_on_the_wire() {
882        use crate::VoiceDocumentLibraryAction as Action;
883        let cases = [
884            (
885                Action::CreateFolder {
886                    name: "Meetings".into(),
887                },
888                json!({"action":"create_folder","name":"Meetings"}),
889            ),
890            (
891                Action::RenameFolder {
892                    folder_id: "f_1".into(),
893                    name: "Archive".into(),
894                },
895                json!({"action":"rename_folder","folder_id":"f_1","name":"Archive"}),
896            ),
897            (
898                Action::RemoveFolder {
899                    folder_id: "f_1".into(),
900                },
901                json!({"action":"remove_folder","folder_id":"f_1"}),
902            ),
903            (
904                Action::ReorderRoot {
905                    root_order: vec!["folder:f_1".into(), "document:notes.md".into()],
906                },
907                json!({"action":"reorder_root","root_order":["folder:f_1","document:notes.md"]}),
908            ),
909            (
910                Action::ReorderRoot { root_order: vec![] },
911                json!({"action":"reorder_root","root_order":[]}),
912            ),
913            (
914                Action::Rename {
915                    document_path: "notes.md".into(),
916                    name: "Summary".into(),
917                },
918                json!({"action":"rename","document_path":"notes.md","name":"Summary"}),
919            ),
920            (
921                Action::Move {
922                    document_path: "notes.md".into(),
923                    folder_id: "".into(),
924                },
925                json!({"action":"move","document_path":"notes.md","folder_id":""}),
926            ),
927            (
928                Action::Restore {
929                    document_path: "notes.md".into(),
930                },
931                json!({"action":"restore","document_path":"notes.md"}),
932            ),
933        ];
934        let response = "{\"v\":1,\"ok\":true,\"result\":{\"folders\":[],\"root_order\":[],\"documents\":[{\"status\":\"archived\"}]}}\n";
935        let (endpoint, server) = server_sequence(vec![response; cases.len() + 1]);
936        let client = CCCCClient::new(endpoint);
937        let library = client
938            .assistant_voice_document_library("g_1")
939            .expect("library");
940        assert_eq!(library.documents[0]["status"], "archived");
941        for (action, _) in &cases {
942            client
943                .assistant_voice_document_library_update("g_1", action, Some("foreman"))
944                .expect("update");
945        }
946        let requests: Vec<Value> = server
947            .join()
948            .expect("server")
949            .iter()
950            .map(|line| serde_json::from_str(line).expect("JSON"))
951            .collect();
952        assert_eq!(
953            requests[0],
954            json!({"v":1,"op":"assistant_voice_document_library","args":{"group_id":"g_1"}})
955        );
956        for (index, (_, mut expected)) in cases.into_iter().enumerate() {
957            expected["group_id"] = json!("g_1");
958            expected["by"] = json!("foreman");
959            assert_eq!(
960                requests[index + 1],
961                json!({"v":1,"op":"assistant_voice_document_library_update","args":expected})
962            );
963        }
964    }
965
966    #[test]
967    fn voice_delete_preserves_recording_error() {
968        let (endpoint, server) = server_once("{\"v\":1,\"ok\":false,\"error\":{\"code\":\"voice_recording_active\",\"message\":\"Stop recording\"}}\n");
969        let error = CCCCClient::new(endpoint)
970            .assistant_voice_document_delete("g_1", "notes.md", None)
971            .expect_err("recording");
972        assert!(
973            matches!(error, Error::Daemon(crate::DaemonError { ref code, .. }) if code == "voice_recording_active")
974        );
975        let request: Value = serde_json::from_str(&server.join().expect("server")).expect("JSON");
976        assert_eq!(
977            request,
978            json!({"v":1,"op":"assistant_voice_document_delete","args":{"group_id":"g_1","document_path":"notes.md","by":"user"}})
979        );
980    }
981
982    #[test]
983    fn preserves_structured_daemon_errors() {
984        let (endpoint, server) = server_once(
985            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
986        );
987        let error = CCCCClient::new(endpoint)
988            .call("future_op", Map::new())
989            .expect_err("daemon error");
990        assert!(matches!(
991            error,
992            Error::Daemon(crate::DaemonError { ref code, .. }) if code == "unknown_op"
993        ));
994        server.join().expect("server thread");
995    }
996
997    #[test]
998    fn maps_current_message_delivery_and_inbox_operations() {
999        let response = "{\"v\":1,\"ok\":true,\"result\":{}}\n";
1000        let (endpoint, server) = server_sequence(vec![response; 7]);
1001        let client = CCCCClient::new(endpoint);
1002
1003        client
1004            .send("g_1", "FYI", MessageMode::Mail, "user")
1005            .expect("send Mail");
1006        client
1007            .reply_with_mode(
1008                "g_1",
1009                "e_request",
1010                "quiet follow-up",
1011                ReplyMessageMode::Mail,
1012                "peer-1",
1013            )
1014            .expect("reply with Mail");
1015        client
1016            .reply_request_cancel("g_1", "e_request", "user")
1017            .expect("cancel reply request");
1018        client
1019            .message_deliver("g_1", "e_mail", &["peer-1"], "user", true)
1020            .expect("deliver existing message");
1021        client
1022            .inbox_peek("g_1", "peer-1", "user", Some(5))
1023            .expect("peek Inbox");
1024        client
1025            .inbox_read("g_1", "peer-1", "peer-1", Some(3))
1026            .expect("read Inbox");
1027        client
1028            .message_history(
1029                "g_1",
1030                "peer-1",
1031                "user",
1032                MessageHistoryMode::Send,
1033                Some("decision"),
1034                Some("e_before"),
1035                Some(7),
1036            )
1037            .expect("read message history");
1038
1039        let requests: Vec<Value> = server
1040            .join()
1041            .expect("server thread")
1042            .iter()
1043            .map(|request| serde_json::from_str(request).expect("request JSON"))
1044            .collect();
1045        assert_eq!(requests[0]["args"]["message_mode"], "mail");
1046        assert_eq!(requests[1]["op"], "reply");
1047        assert_eq!(requests[1]["args"]["message_mode"], "mail");
1048        assert_eq!(requests[2]["op"], "reply_request_cancel");
1049        assert_eq!(requests[3]["args"]["actor_ids"], json!(["peer-1"]));
1050        assert_eq!(requests[3]["args"]["force_ambiguous"], true);
1051        assert_eq!(requests[4]["op"], "inbox_peek");
1052        assert_eq!(requests[5]["op"], "inbox_read");
1053        assert_eq!(requests[6]["op"], "message_history");
1054        assert_eq!(requests[6]["args"]["mode"], "send");
1055        assert_eq!(requests[6]["args"]["query"], "decision");
1056        assert_eq!(requests[6]["args"]["before_event_id"], "e_before");
1057        assert_eq!(requests[6]["args"]["limit"], 7);
1058        assert!(matches!(
1059            client.message_deliver("g_1", "e_mail", &[], "user", false),
1060            Err(Error::InvalidArgument(_))
1061        ));
1062    }
1063
1064    #[test]
1065    fn exact_history_rejects_old_daemon_list_responses() {
1066        let responses = vec![
1067            "{\"v\":1,\"ok\":true,\"result\":{\"messages\":[{\"id\":\"e_1\"}],\"has_more\":false,\"delivery_text\":null}}\n",
1068            "{\"v\":1,\"ok\":true,\"result\":{\"messages\":[{\"id\":\"e_1\"}],\"has_more\":false}}\n",
1069            "{\"v\":1,\"ok\":true,\"result\":{\"messages\":[{\"id\":\"other\"}],\"has_more\":false,\"delivery_text\":\"text\"}}\n",
1070            "{\"v\":1,\"ok\":true,\"result\":{\"messages\":[],\"has_more\":false,\"delivery_text\":null}}\n",
1071        ];
1072        let (endpoint, server) = server_sequence(responses);
1073        let client = CCCCClient::new(endpoint);
1074        let result = client
1075            .message_history_exact("g_1", "peer", "e_1", "foreman")
1076            .unwrap();
1077        assert_eq!(result["delivery_text"], Value::Null);
1078        for _ in 0..3 {
1079            assert!(matches!(
1080                client.message_history_exact("g_1", "peer", "e_1", "user"),
1081                Err(Error::Incompatible(_))
1082            ));
1083        }
1084        assert!(matches!(
1085            client.message_history_exact("g_1", "peer", " ", "user"),
1086            Err(Error::InvalidArgument(_))
1087        ));
1088        let requests: Vec<Value> = server
1089            .join()
1090            .unwrap()
1091            .iter()
1092            .map(|line| serde_json::from_str(line).unwrap())
1093            .collect();
1094        assert_eq!(
1095            requests[0],
1096            json!({"v":1,"op":"message_history","args":{
1097                "group_id":"g_1","actor_id":"peer","event_id":"e_1","by":"foreman"
1098            }})
1099        );
1100    }
1101
1102    #[test]
1103    fn inbox_summary_is_a_read_only_current_group_view() {
1104        let (endpoint, server) = server_once("{\"v\":1,\"ok\":true,\"result\":{\"group_id\":\"g_1\",\"observed_at\":\"2026-11-03T00:00:00Z\",\"cutoff\":\"2026-11-02T00:00:00Z\",\"actors\":[{\"actor_id\":\"peer\",\"awaiting_count\":0,\"overdue_count\":0,\"oldest_awaiting_at\":null,\"oldest_age_seconds\":null}]}}\n");
1105        let result = CCCCClient::new(endpoint)
1106            .inbox_summary("g_1", "2026-11-02T00:00:00Z", "foreman")
1107            .unwrap();
1108        assert_eq!(result.actors[0].awaiting_count, 0);
1109        assert_eq!(result.actors[0].oldest_awaiting_at, None);
1110        let request: Value = serde_json::from_str(&server.join().unwrap()).unwrap();
1111        assert_eq!(
1112            request,
1113            json!({"v":1,"op":"inbox_summary","args":{
1114                "group_id":"g_1","cutoff":"2026-11-02T00:00:00Z","by":"foreman"
1115            }})
1116        );
1117        assert!(operation_probe_is_safe("inbox_summary"));
1118    }
1119
1120    #[test]
1121    fn context_get_uses_the_current_projection_contract() {
1122        let (endpoint, server) = server_once("{\"v\":1,\"ok\":true,\"result\":{}}\n");
1123        CCCCClient::new(endpoint)
1124            .context_get_with_detail("g_1", ContextDetail::Summary)
1125            .expect("summary context");
1126        let request: Value =
1127            serde_json::from_str(&server.join().expect("server thread")).expect("request JSON");
1128        assert_eq!(
1129            request,
1130            json!({"v": 1, "op": "context_get", "args": {"group_id": "g_1", "detail": "summary"}})
1131        );
1132    }
1133
1134    #[test]
1135    fn never_probes_destructive_or_streaming_operations() {
1136        assert!(!operation_probe_is_safe("shutdown"));
1137        assert!(!operation_probe_is_safe("group_create"));
1138        assert!(!operation_probe_is_safe("remote_access_start"));
1139        assert!(!operation_probe_is_safe("term_attach"));
1140        assert!(operation_probe_is_safe("group_show"));
1141    }
1142
1143    #[test]
1144    fn call_raw_rejects_an_unsupported_response_version() {
1145        let (endpoint, server) = server_once("{\"v\":2,\"ok\":true,\"result\":{}}\n");
1146        let error = CCCCClient::new(endpoint)
1147            .call_raw("ping", Map::new())
1148            .expect_err("unsupported version");
1149        assert!(matches!(error, Error::UnsupportedIpcVersion(2)));
1150        server.join().expect("server thread");
1151    }
1152
1153    #[test]
1154    fn maps_current_terminal_and_web_model_operations() {
1155        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";
1156        let (endpoint, server) = server_sequence(vec![
1157            "{\"v\":1,\"ok\":true,\"result\":{\"data\":\"screen\",\"start_cursor\":1,\"end_cursor\":7}}\n",
1158            preference,
1159            preference,
1160            "{\"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",
1161        ]);
1162        let client = CCCCClient::new(endpoint);
1163
1164        let snapshot = client
1165            .terminal_snapshot(
1166                "g_1",
1167                "web-1",
1168                &TerminalSnapshotOptions {
1169                    limit_bytes: Some(4096),
1170                    by: Some("user".into()),
1171                },
1172            )
1173            .expect("terminal snapshot");
1174        assert_eq!(snapshot.end_cursor, 7);
1175        let preference = client
1176            .web_model_delivery_preferences_get("g_1", "web-1")
1177            .expect("preference get");
1178        assert_eq!(
1179            preference.preference.mode,
1180            WebModelDeliveryMode::ImageCompat
1181        );
1182        client
1183            .web_model_delivery_preferences_update(
1184                "g_1",
1185                "web-1",
1186                WebModelDeliveryMode::ImageCompat,
1187                "user",
1188            )
1189            .expect("preference update");
1190        let recovered = client
1191            .web_model_runtime_recover_turn("g_1", "web-1", &["e_1".into()])
1192            .expect("recover turn");
1193        assert_eq!(recovered.status, "recovered");
1194
1195        let requests: Vec<Value> = server
1196            .join()
1197            .expect("server thread")
1198            .iter()
1199            .map(|request| serde_json::from_str(request).expect("request JSON"))
1200            .collect();
1201        assert_eq!(
1202            requests
1203                .iter()
1204                .map(|request| request["op"].as_str().expect("op"))
1205                .collect::<Vec<_>>(),
1206            vec![
1207                "terminal_snapshot",
1208                "web_model_delivery_preferences_get",
1209                "web_model_delivery_preferences_update",
1210                "web_model_runtime_recover_turn",
1211            ]
1212        );
1213        assert_eq!(requests[0]["args"]["limit_bytes"], 4096);
1214        assert_eq!(requests[2]["args"]["mode"], "image_compat");
1215        assert_eq!(requests[3]["args"]["event_ids"], json!(["e_1"]));
1216    }
1217
1218    #[test]
1219    fn term_resize_prefers_the_standard_op_and_falls_back_for_unknown_op() {
1220        let (endpoint, server) = server_sequence(vec![
1221            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
1222            "{\"v\":1,\"ok\":true,\"result\":{\"resized\":true,\"cols\":120,\"rows\":40}}\n",
1223        ]);
1224        let resized = CCCCClient::new(endpoint)
1225            .term_resize("g_1", "a_1", 120, 40)
1226            .expect("resize");
1227        assert_eq!(resized.group_id, "g_1");
1228        assert_eq!(resized.actor_id, "a_1");
1229        assert_eq!(resized.cols, 120);
1230        let requests = server.join().expect("server thread");
1231        let operations: Vec<String> = requests
1232            .iter()
1233            .map(|request| {
1234                serde_json::from_str::<Value>(request).expect("request JSON")["op"]
1235                    .as_str()
1236                    .expect("op")
1237                    .to_owned()
1238            })
1239            .collect();
1240        assert_eq!(operations, vec!["term_resize", "terminal_resize"]);
1241    }
1242
1243    #[test]
1244    fn compatibility_probe_accepts_the_bounded_resize_alias() {
1245        let (endpoint, server) = server_sequence(vec![
1246            "{\"v\":1,\"ok\":true,\"result\":{\"version\":\"test\",\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
1247            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
1248            "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"invalid_request\",\"message\":\"missing args\",\"details\":{}}}\n",
1249        ]);
1250        let requirements = CompatibilityRequirements {
1251            minimum_ipc_version: 1,
1252            operations: vec!["term_resize"],
1253            ..Default::default()
1254        };
1255        CCCCClient::new(endpoint)
1256            .assert_compatible(&requirements)
1257            .expect("legacy alias is usable through the SDK");
1258        let operations: Vec<String> = server
1259            .join()
1260            .expect("server thread")
1261            .iter()
1262            .map(|request| {
1263                serde_json::from_str::<Value>(request).expect("request JSON")["op"]
1264                    .as_str()
1265                    .expect("op")
1266                    .to_owned()
1267            })
1268            .collect();
1269        assert_eq!(operations, vec!["ping", "term_resize", "terminal_resize"]);
1270    }
1271
1272    #[test]
1273    fn unknown_or_side_effectful_probes_fail_without_dispatch() {
1274        for op in [
1275            "connect_direct_configure",
1276            "membership_logout",
1277            "future_mutation",
1278        ] {
1279            let (endpoint, server) = server_sequence(vec![
1280                "{\"v\":1,\"ok\":true,\"result\":{\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
1281            ]);
1282            let requirements = CompatibilityRequirements {
1283                operations: vec![op],
1284                ..Default::default()
1285            };
1286            let error = CCCCClient::new(endpoint)
1287                .assert_compatible(&requirements)
1288                .unwrap_err();
1289            assert!(matches!(error, Error::Incompatible(_)));
1290            assert!(error.to_string().contains("cannot safely verify"));
1291            assert_eq!(server.join().unwrap().len(), 1);
1292        }
1293    }
1294
1295    #[test]
1296    fn connect_and_checked_context_keep_identity_and_conflict_tokens() {
1297        let (endpoint, server) = server_sequence(vec![
1298            "{\"v\":1,\"ok\":true,\"result\":{\"accepted\":true}}\n",
1299            "{\"v\":1,\"ok\":false,\"error\":{\"code\":\"version_conflict\",\"message\":\"changed\",\"details\":{}}}\n",
1300        ]);
1301        let client = CCCCClient::new(endpoint);
1302        let mut options = crate::ConnectSendOptions {
1303            group_id: "local".into(),
1304            instance_id: "peer".into(),
1305            target_group_id: "remote".into(),
1306            client_id: "key".into(),
1307            text: "hello".into(),
1308            message_mode: MessageMode::Mail,
1309            by: None,
1310            to: None,
1311            format: None,
1312            insight: None,
1313            attachments: None,
1314        };
1315        client.connect_send(&options).unwrap();
1316        let error = client
1317            .context_sync_checked("local", "user", vec![], Some("version"), false)
1318            .unwrap_err();
1319        assert!(matches!(error, Error::Daemon(ref e) if e.code == "version_conflict"));
1320        for key in ["".to_owned(), "界".repeat(43)] {
1321            options.client_id = key;
1322            assert!(matches!(
1323                client.connect_send(&options),
1324                Err(Error::InvalidArgument(_))
1325            ));
1326        }
1327        let calls: Vec<Value> = server
1328            .join()
1329            .unwrap()
1330            .iter()
1331            .map(|line| serde_json::from_str(line).unwrap())
1332            .collect();
1333        assert_eq!(calls[0]["args"]["instance_id"], "peer");
1334        assert_eq!(calls[0]["args"]["target_group_id"], "remote");
1335        assert_eq!(calls[0]["args"]["client_id"], "key");
1336        assert_eq!(calls[0]["args"]["message_mode"], "mail");
1337        assert_eq!(calls[1]["args"]["if_version"], "version");
1338    }
1339
1340    #[test]
1341    fn rediscovers_endpoint_after_connect_failure_before_exchange() {
1342        let home = temp_home("rediscover");
1343        let stale = TcpListener::bind("127.0.0.1:0").expect("bind stale port");
1344        let stale_port = stale.local_addr().expect("stale address").port();
1345        drop(stale);
1346        write_tcp_descriptor(&home, stale_port);
1347        let client = CCCCClient::discover_in(Some(&home)).expect("discover stale endpoint");
1348
1349        let listener = TcpListener::bind("127.0.0.1:0").expect("bind live server");
1350        let live_port = listener.local_addr().expect("live address").port();
1351        write_tcp_descriptor(&home, live_port);
1352        let server = thread::spawn(move || {
1353            let (mut stream, _) = listener.accept().expect("accept client");
1354            let mut request = String::new();
1355            BufReader::new(&mut stream)
1356                .read_line(&mut request)
1357                .expect("read request");
1358            stream
1359                .write_all(b"{\"v\":1,\"ok\":true,\"result\":{\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n")
1360                .expect("write response");
1361        });
1362
1363        assert_eq!(client.ping().expect("ping").ipc_v, 1);
1364        assert_eq!(
1365            client.current_endpoint(),
1366            DaemonEndpoint::Tcp {
1367                host: "127.0.0.1".into(),
1368                port: live_port,
1369            }
1370        );
1371        server.join().expect("server thread");
1372        fs::remove_dir_all(home).expect("cleanup");
1373    }
1374
1375    #[test]
1376    fn exchange_failure_is_reported_as_outcome_unknown_without_replay() {
1377        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
1378        let address = listener.local_addr().expect("local address");
1379        let server = thread::spawn(move || {
1380            let (mut stream, _) = listener.accept().expect("accept client");
1381            let mut request = String::new();
1382            BufReader::new(&mut stream)
1383                .read_line(&mut request)
1384                .expect("read request");
1385        });
1386        let client = CCCCClient::new(DaemonEndpoint::Tcp {
1387            host: "127.0.0.1".into(),
1388            port: address.port(),
1389        });
1390        assert!(matches!(
1391            client.call("non_idempotent_write", Map::new()),
1392            Err(Error::OutcomeUnknown { ref op, .. }) if op == "non_idempotent_write"
1393        ));
1394        server.join().expect("server thread");
1395    }
1396}