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