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#[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#[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 pub fn discover() -> Result<Self> {
49 Self::discover_in(None)
50 }
51
52 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 pub fn endpoint(&self) -> &DaemonEndpoint {
80 &self.endpoint
81 }
82
83 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 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 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 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 #[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 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 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 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 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 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 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
700fn 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}