Skip to main content

superdoc_sdk/
client.rs

1use crate::{
2    generated::{
3        operations::{DocApi, DocApiPreset},
4        types::{DocCloseBoundParams, DocCloseResult, DocOpenParams, DocOpenResult},
5    },
6    invoke::Invoker,
7    runtime::{
8        contract, policy,
9        process::{CallState, HostOptions, Transport},
10        routing, trim_js_whitespace,
11    },
12    Error, InvokeOptions, Result,
13};
14use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
15use serde_json::{json, Value};
16use std::{
17    collections::BTreeSet,
18    path::Path,
19    sync::{
20        atomic::{AtomicU64, AtomicU8, Ordering},
21        Arc, Mutex,
22    },
23    time::{Duration, SystemTime, UNIX_EPOCH},
24};
25use tokio::sync::{broadcast, watch, Notify};
26
27#[derive(Clone)]
28pub struct CollaborationAuth {
29    token: String,
30}
31impl CollaborationAuth {
32    pub fn token(token: impl Into<String>) -> Result<Self> {
33        let token = token.into();
34        if token.is_empty() {
35            return Err(Error::new(
36                "INVALID_ARGUMENT",
37                "collaboration token must not be empty",
38            ));
39        }
40        Ok(Self { token })
41    }
42    fn json(&self) -> Value {
43        json!({"type":"token","token":self.token})
44    }
45}
46impl std::fmt::Debug for CollaborationAuth {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.write_str("CollaborationAuth([redacted])")
49    }
50}
51
52#[derive(Clone, Debug, Default)]
53pub struct OpenOptions {
54    pub invoke: InvokeOptions,
55    pub collaboration_auth: Option<CollaborationAuth>,
56}
57
58#[derive(Clone, Default)]
59pub struct ClientOptions {
60    pub default_change_mode: Option<String>,
61    pub user_name: Option<String>,
62    pub user_email: Option<String>,
63}
64
65struct ClientInner {
66    transport: Transport,
67    options: ClientOptions,
68    opening: Mutex<BTreeSet<String>>,
69    lifecycle: Mutex<ClientLifecycle>,
70    idle: Notify,
71}
72
73#[derive(Default)]
74struct ClientLifecycle {
75    active: usize,
76    stopping: bool,
77    opened_documents: bool,
78    shutdown: Option<watch::Receiver<Option<Result<()>>>>,
79}
80
81struct ActiveCall(Arc<ClientInner>);
82
83impl Drop for ActiveCall {
84    fn drop(&mut self) {
85        let mut lifecycle = self.0.lifecycle.lock().expect("SDK lifecycle lock");
86        lifecycle.active -= 1;
87        if lifecycle.active == 0 {
88            self.0.idle.notify_one();
89        }
90    }
91}
92
93#[derive(Clone)]
94pub struct Client(Arc<ClientInner>);
95
96struct OpenGuard {
97    client: Client,
98    id: String,
99}
100impl Drop for OpenGuard {
101    fn drop(&mut self) {
102        self.client
103            .0
104            .opening
105            .lock()
106            .expect("SDK opening lock")
107            .remove(&self.id);
108    }
109}
110
111impl Client {
112    pub async fn connect(host: HostOptions) -> Result<Self> {
113        Self::connect_with_options(host, ClientOptions::default()).await
114    }
115
116    pub async fn connect_with_options(host: HostOptions, options: ClientOptions) -> Result<Self> {
117        if options
118            .default_change_mode
119            .as_deref()
120            .is_some_and(|mode| !["direct", "tracked"].contains(&mode))
121        {
122            return Err(Error::new(
123                "INVALID_ARGUMENT",
124                "default change mode must be direct or tracked",
125            ));
126        }
127        if options
128            .user_name
129            .as_deref()
130            .is_some_and(|name| trim_js_whitespace(name).is_empty())
131            || (options.user_email.is_some() && options.user_name.is_none())
132        {
133            return Err(Error::new(
134                "INVALID_ARGUMENT",
135                "user identity requires a non-empty name",
136            ));
137        }
138        Ok(Self(Arc::new(ClientInner {
139            transport: Transport::connect(host).await?,
140            options,
141            opening: Mutex::new(BTreeSet::new()),
142            lifecycle: Mutex::new(ClientLifecycle::default()),
143            idle: Notify::new(),
144        })))
145    }
146
147    /// Local contract introspection requires no running host.
148    pub fn describe() -> Value {
149        contract()["introspection"]["overview"].clone()
150    }
151
152    pub fn describe_command(lookup: &str) -> Result<Value> {
153        let index = contract()["introspection"]["lookup"]
154            [trim_js_whitespace(lookup).to_lowercase()]
155        .as_u64()
156        .ok_or_else(|| {
157            Error::new(
158                "OPERATION_NOT_FOUND",
159                "no contract operation matches the requested lookup",
160            )
161        })?;
162        Ok(contract()["introspection"]["overview"]["operations"][index as usize].clone())
163    }
164
165    pub fn subscribe(&self) -> Notifications {
166        Notifications(self.0.transport.subscribe())
167    }
168    pub fn state_directory(&self) -> &Path {
169        self.0.transport.state_directory()
170    }
171
172    pub async fn open(&self, params: &DocOpenParams) -> Result<Document> {
173        self.open_with_options(params, OpenOptions::default()).await
174    }
175
176    pub async fn open_with_options(
177        &self,
178        params: &DocOpenParams,
179        options: OpenOptions,
180    ) -> Result<Document> {
181        let _active = self.begin_call()?;
182        let mut params = serde_json::to_value(params)
183            .map_err(|_| Error::new("INVALID_ARGUMENT", "open parameters could not be encoded"))?;
184        let session_id = match params.get("sessionId") {
185            Some(value) => value
186                .as_str()
187                .filter(|id| !id.is_empty())
188                .ok_or_else(|| Error::new("INVALID_ARGUMENT", "session ID must be non-empty"))?
189                .to_owned(),
190            None => fresh_session_id(),
191        };
192        {
193            let mut opening = self.0.opening.lock().expect("SDK opening lock");
194            if self.0.transport.has_session(&session_id) || !opening.insert(session_id.clone()) {
195                let mut error = Error::new(
196                    "SESSION_ALREADY_OPEN",
197                    "session ID is already open or opening",
198                );
199                error.details = Some(Box::new(json!({"sessionId":session_id})));
200                return Err(error);
201            }
202        }
203        let _guard = OpenGuard {
204            client: self.clone(),
205            id: session_id.clone(),
206        };
207        params["sessionId"] = json!(session_id);
208
209        let result = {
210            if let Some(name) = &self.0.options.user_name {
211                if params.get("userName").is_none() {
212                    params["userName"] = json!(name);
213                }
214            }
215            if let Some(email) = &self.0.options.user_email {
216                if params.get("userEmail").is_none() {
217                    params["userEmail"] = json!(email);
218                }
219            }
220            self.invoke_cli(
221                "doc.open",
222                params,
223                &options.invoke,
224                options.collaboration_auth.as_ref(),
225            )
226            .await
227        };
228        let result = match result {
229            Ok(value) => serde_json::from_value::<DocOpenResult>(value).map_err(Error::decode),
230            Err(mut error) => {
231                error.operation_id = Some("doc.open".into());
232                if ["TIMEOUT", "HOST_DISCONNECTED", "HOST_PROTOCOL_ERROR"]
233                    .contains(&error.code.as_str())
234                {
235                    let _ = self.0.transport.shutdown().await;
236                    error.outcome_unknown = true;
237                }
238                return Err(error);
239            }
240        };
241        let result = match result {
242            Ok(result) if result.context_id == session_id && result.active => result,
243            invalid => {
244                // An invalid open result cannot leave an unreachable native
245                // session alive. Retiring also invalidates any sibling handles.
246                let _ = self.0.transport.shutdown().await;
247                let mut error = match invalid {
248                    Err(error) => error,
249                    Ok(_) => Error::new(
250                        "HOST_PROTOCOL_ERROR",
251                        "host returned an invalid open result",
252                    ),
253                };
254                error.operation_id = Some("doc.open".into());
255                error.outcome_unknown = true;
256                return Err(error);
257            }
258        };
259        let state = self.0.transport.session_state(&session_id);
260        if state.load(Ordering::Acquire) != 0 {
261            return Err(Error::new(
262                "DOCUMENT_CLOSED",
263                "session closed while it was opening",
264            ));
265        }
266        self.0
267            .lifecycle
268            .lock()
269            .expect("SDK lifecycle lock")
270            .opened_documents = true;
271
272        Ok(Document(Arc::new(DocumentInner {
273            client: self.clone(),
274            session_id,
275            state,
276            open_result: result,
277        })))
278    }
279
280    pub async fn shutdown(&self) -> Result<()> {
281        let mut completion = {
282            let mut lifecycle = self.0.lifecycle.lock().expect("SDK lifecycle lock");
283            if let Some(completion) = &lifecycle.shutdown {
284                completion.clone()
285            } else {
286                let (result, completion) = watch::channel(None);
287                lifecycle.stopping = true;
288                lifecycle.shutdown = Some(completion.clone());
289                let client = self.clone();
290                tokio::spawn(async move {
291                    let outcome = client.shutdown_owned().await;
292                    if outcome.as_ref().is_err_and(|error| !error.outcome_unknown)
293                        && !client.0.transport.is_closed()
294                    {
295                        let mut lifecycle = client.0.lifecycle.lock().expect("SDK lifecycle lock");
296                        lifecycle.stopping = false;
297                        lifecycle.shutdown = None;
298                    }
299                    result.send_replace(Some(outcome));
300                });
301                completion
302            }
303        };
304        loop {
305            if let Some(result) = completion.borrow().clone() {
306                return result;
307            }
308            completion.changed().await.map_err(|_| {
309                Error::new(
310                    "HOST_SHUTDOWN_FAILED",
311                    "managed shutdown owner stopped before confirming completion",
312                )
313            })?;
314        }
315    }
316
317    fn begin_call(&self) -> Result<ActiveCall> {
318        let mut lifecycle = self.0.lifecycle.lock().expect("SDK lifecycle lock");
319        if lifecycle.stopping {
320            return Err(Error::new("HOST_DISCONNECTED", "host is disposing"));
321        }
322        lifecycle.active += 1;
323        Ok(ActiveCall(self.0.clone()))
324    }
325
326    async fn shutdown_owned(&self) -> Result<()> {
327        loop {
328            let idle = self.0.idle.notified();
329            if self.0.lifecycle.lock().expect("SDK lifecycle lock").active == 0 {
330                break;
331            }
332            idle.await;
333        }
334        self.0.transport.drain().await?;
335        if self.0.transport.is_closed() {
336            return self.0.transport.shutdown().await;
337        }
338        if !self
339            .0
340            .lifecycle
341            .lock()
342            .expect("SDK lifecycle lock")
343            .opened_documents
344        {
345            return self.0.transport.shutdown().await;
346        }
347        self.0.transport.shutdown_checked().await.map_err(|error| {
348            let mut failure = Error::new(
349                "HOST_SHUTDOWN_FAILED",
350                "host shutdown failed; its state directory has been retained",
351            );
352            failure.outcome_unknown = error.outcome_unknown;
353            failure.exit_code = error.exit_code;
354            failure.details = Some(Box::new(json!({
355                "path": self.state_directory(),
356                "exitCode": failure.exit_code.as_deref(),
357                "cause": {"code": error.code, "details": error.details}
358            })));
359            failure
360        })
361    }
362
363    pub fn presets(&self) -> DocApiPreset<'_, Self> {
364        DocApiPreset::new(self)
365    }
366
367    fn require_feature(&self, key: &str) -> Result<()> {
368        if !self
369            .0
370            .transport
371            .features()
372            .contains(policy()["features"][key].as_str().unwrap_or(""))
373        {
374            return Err(Error::new(
375                "CAPABILITY_UNSUPPORTED",
376                "connected host lacks the requested optional capability",
377            ));
378        }
379        Ok(())
380    }
381
382    fn unsupported_collaboration_auth() -> Error {
383        let mut error = Error::new("CAPABILITY_UNSUPPORTED", {
384            "The connected CLI host does not support per-open collaboration authentication."
385        });
386        error.details = Some(Box::new(json!({
387            "feature": policy()["features"]["collaborationAuthPerOpen"],
388        })));
389        error.operation_id = Some("doc.open".into());
390        error
391    }
392
393    #[allow(clippy::too_many_arguments)]
394    async fn rpc_tracked(
395        &self,
396        method: &str,
397        params: Value,
398        options: &InvokeOptions,
399        mutates: bool,
400        open: bool,
401        supports_deadline: bool,
402        state: Option<Arc<CallState>>,
403    ) -> Result<Value> {
404        let effective = options
405            .timeout
406            .unwrap_or(self.0.transport.options().request_timeout);
407        let headroom =
408            Duration::from_millis(policy()["watchdogHeadroomMs"].as_u64().unwrap_or(5000));
409        if options.timeout.is_some() {
410            self.require_feature("requestTimeout")?;
411            if !supports_deadline {
412                return Err(Error::new(
413                    "DOCUMENT_RPC_INPUT_UNSUPPORTED",
414                    "this native operation does not support an explicit request deadline",
415                ));
416            }
417        }
418        let deadline = (supports_deadline
419            && self.0.transport.features().contains(
420                policy()["features"]["requestTimeout"]
421                    .as_str()
422                    .unwrap_or(""),
423            ))
424        .then_some(effective);
425        self.0
426            .transport
427            .request_tracked(
428                method,
429                params,
430                deadline,
431                effective.saturating_add(headroom),
432                mutates,
433                open,
434                state.unwrap_or_default(),
435            )
436            .await
437    }
438
439    fn reject_unsupported_dispatch_preview(
440        &self,
441        id: &str,
442        options: &InvokeOptions,
443        full: bool,
444    ) -> Result<()> {
445        if id == "doc.preset.dispatch" && options.dry_run == Some(true) && !full {
446            let mut error = Error::new(
447                "CAPABILITY_UNSUPPORTED",
448                "The core preset dispatcher does not support dryRun previews.",
449            );
450            error.operation_id = Some(id.into());
451            return Err(error);
452        }
453        Ok(())
454    }
455
456    async fn invoke_cli(
457        &self,
458        id: &str,
459        params: Value,
460        options: &InvokeOptions,
461        auth: Option<&CollaborationAuth>,
462    ) -> Result<Value> {
463        self.invoke_cli_tracked(id, params, options, auth, None)
464            .await
465    }
466
467    async fn invoke_cli_tracked(
468        &self,
469        id: &str,
470        params: Value,
471        options: &InvokeOptions,
472        auth: Option<&CollaborationAuth>,
473        state: Option<Arc<CallState>>,
474    ) -> Result<Value> {
475        let operation = operation(id)?;
476        if auth.is_some() {
477            if id != "doc.open" {
478                return Err(Error::new(
479                    "INVALID_ARGUMENT",
480                    "authentication is scoped to document open",
481                ));
482            }
483            self.require_feature("collaborationAuthPerOpen")
484                .map_err(|_| Self::unsupported_collaboration_auth())?;
485        }
486        if options
487            .stdin_bytes
488            .as_ref()
489            .is_some_and(|bytes| bytes.len() > 32 * 1024 * 1024)
490        {
491            return Err(Error::new(
492                "PAYLOAD_TOO_LARGE",
493                "stdin exceeds the host's 32 MiB limit",
494            ));
495        }
496        validate_json(&params)?;
497        let mut request = json!({"argv":routing::argv(operation,&params,options,self.0.options.default_change_mode.as_deref())?,"stdinBase64":options.stdin_bytes.as_ref().map(|bytes|BASE64.encode(bytes)).unwrap_or_default()});
498        if let Some(auth) = auth {
499            request["collaborationAuth"] = auth.json();
500        }
501        let response = self
502            .rpc_tracked(
503                "cli.invoke",
504                request,
505                options,
506                operation["mutates"] == true,
507                id == "doc.open",
508                true,
509                state,
510            )
511            .await?;
512        routing::map_cli(operation, response).map_err(|mut error| {
513            error.outcome_unknown = operation["mutates"] == true;
514            error
515        })
516    }
517
518    pub async fn get_tools(&self, provider: &str) -> Result<Value> {
519        let _active = self.begin_call()?;
520        self.invoke_cli(
521            "doc.preset.getTools",
522            json!({"provider":provider,"preset":"core"}),
523            &InvokeOptions::default(),
524            None,
525        )
526        .await
527    }
528
529    pub async fn get_system_prompt(&self) -> Result<Value> {
530        let _active = self.begin_call()?;
531        self.invoke_cli(
532            "doc.preset.getSystemPrompt",
533            json!({"preset":"core"}),
534            &InvokeOptions::default(),
535            None,
536        )
537        .await
538    }
539}
540
541impl Invoker for Client {
542    async fn invoke(&self, id: &str, params: Value, options: InvokeOptions) -> Result<Value> {
543        let _active = self.begin_call()?;
544        if ![
545            "doc.preset.list",
546            "doc.preset.getCatalog",
547            "doc.preset.getTools",
548            "doc.preset.getSystemPrompt",
549            "doc.preset.getMcpPrompt",
550        ]
551        .contains(&id)
552            || operation(id)?["requiresDocumentContext"] != false
553            || operation(id)?["mutates"] != false
554        {
555            return Err(Error::new(
556                "INVALID_ARGUMENT",
557                "client only invokes sessionless preset getters",
558            ));
559        }
560        self.invoke_cli(id, params, &options, None).await
561    }
562}
563
564fn operation(id: &str) -> Result<&'static Value> {
565    contract()["operations"].get(id).ok_or_else(|| {
566        Error::new(
567            "OPERATION_NOT_FOUND",
568            "operation is not in the generated contract",
569        )
570    })
571}
572
573fn fresh_session_id() -> String {
574    static NEXT: AtomicU64 = AtomicU64::new(1);
575    format!(
576        "rust-{}-{}-{}",
577        std::process::id(),
578        SystemTime::now()
579            .duration_since(UNIX_EPOCH)
580            .unwrap_or_default()
581            .as_nanos(),
582        NEXT.fetch_add(1, Ordering::Relaxed)
583    )
584}
585
586fn validate_json(value: &Value) -> Result<()> {
587    let mut stack = vec![(value, 0)];
588    while let Some((value, depth)) = stack.pop() {
589        if depth > 128 {
590            return Err(Error::new(
591                "INVALID_ARGUMENT",
592                "request JSON exceeds the supported nesting depth",
593            ));
594        }
595        match value {
596            Value::Number(number)
597                if number.is_i64()
598                    && number.as_i64().is_some_and(|n| {
599                        !(-crate::wire::Integer::MAX..=crate::wire::Integer::MAX).contains(&n)
600                    }) =>
601            {
602                return Err(Error::new(
603                    "INVALID_ARGUMENT",
604                    "JSON integer cannot be represented exactly by the host",
605                ))
606            }
607            Value::Number(number)
608                if number.is_u64()
609                    && number
610                        .as_u64()
611                        .is_some_and(|n| n > crate::wire::Integer::MAX as u64) =>
612            {
613                return Err(Error::new(
614                    "INVALID_ARGUMENT",
615                    "JSON integer cannot be represented exactly by the host",
616                ))
617            }
618            Value::Array(values) => stack.extend(values.iter().map(|value| (value, depth + 1))),
619            Value::Object(values) => stack.extend(values.values().map(|value| (value, depth + 1))),
620            _ => {}
621        }
622    }
623    Ok(())
624}
625
626struct DocumentInner {
627    client: Client,
628    session_id: String,
629    // Active, closing, closed, or outcome unknown. All clones share this state.
630    state: Arc<AtomicU8>,
631    open_result: DocOpenResult,
632}
633
634impl Drop for DocumentInner {
635    fn drop(&mut self) {
636        if self.state.load(Ordering::Acquire) >= 2 {
637            self.client
638                .0
639                .transport
640                .remove_session(&self.session_id, &self.state);
641        }
642    }
643}
644
645#[derive(Clone)]
646pub struct Document(Arc<DocumentInner>);
647
648struct CloseGuard {
649    state: Arc<AtomicU8>,
650    transmission: Arc<CallState>,
651}
652impl Drop for CloseGuard {
653    fn drop(&mut self) {
654        let _ = self.state.compare_exchange(
655            1,
656            if self.transmission.cancel_before_send() {
657                0
658            } else {
659                3
660            },
661            Ordering::AcqRel,
662            Ordering::Acquire,
663        );
664    }
665}
666
667impl Document {
668    pub fn session_id(&self) -> &str {
669        &self.0.session_id
670    }
671    pub fn open_result(&self) -> &DocOpenResult {
672        &self.0.open_result
673    }
674    pub fn api(&self) -> DocApi<'_, Self> {
675        DocApi::new(self)
676    }
677
678    pub async fn close(&self, params: &DocCloseBoundParams) -> Result<DocCloseResult> {
679        self.api().close(params).await
680    }
681
682    pub async fn replace_file(
683        &self,
684        path: impl AsRef<Path>,
685        options: InvokeOptions,
686    ) -> Result<Value> {
687        self.active()?;
688        let _active = self.0.client.begin_call()?;
689        self.replace_path(path.as_ref(), options, None).await
690    }
691
692    /// The transport owns staged bytes until the native reply or confirmed exit,
693    /// including when the caller drops this future after transmission.
694    pub async fn replace_bytes(&self, bytes: &[u8], options: InvokeOptions) -> Result<Value> {
695        use std::io::Write;
696        if bytes.len() > 32 * 1024 * 1024 {
697            return Err(Error::new(
698                "PAYLOAD_TOO_LARGE",
699                "replacement exceeds 32 MiB",
700            ));
701        }
702        self.active()?;
703        let _active = self.0.client.begin_call()?;
704        let mut file = tempfile::NamedTempFile::new_in(self.0.client.state_directory())
705            .map_err(|_| Error::new("IO_ERROR", "could not stage replacement bytes"))?;
706        file.write_all(bytes)
707            .map_err(|_| Error::new("IO_ERROR", "could not stage replacement bytes"))?;
708        let asset = file.into_temp_path();
709        let path = asset.to_path_buf();
710        self.replace_path(&path, options, Some(CallState::with_asset(asset)))
711            .await
712    }
713
714    async fn replace_path(
715        &self,
716        path: &Path,
717        options: InvokeOptions,
718        state: Option<Arc<CallState>>,
719    ) -> Result<Value> {
720        self.active()?;
721        self.0.client.require_feature("replaceFile")?;
722        let path = path
723            .to_str()
724            .ok_or_else(|| Error::new("INVALID_ARGUMENT", "replacement path must be UTF-8"))?;
725        let transmission = state.unwrap_or_default();
726
727        let response = self
728            .0
729            .client
730            .rpc_tracked(
731                "document.replaceFile",
732                json!({"sessionId":self.0.session_id,"path":path}),
733                &options,
734                true,
735                false,
736                false,
737                Some(transmission),
738            )
739            .await;
740
741        let value = response?;
742        if value["sessionId"] != self.0.session_id
743            || value["replaced"] != true
744            || !value["byteLength"].is_number()
745            || !value["revision"].is_string()
746        {
747            let mut error = Error::new(
748                "HOST_PROTOCOL_ERROR",
749                "host returned an invalid replacement result",
750            );
751            error.outcome_unknown = true;
752            return Err(error);
753        }
754        Ok(
755            json!({"contextId":self.0.session_id,"runtime":"v2","replaced":true,"document":{"byteLength":value["byteLength"],"revision":value["revision"]}}),
756        )
757    }
758
759    pub(crate) async fn check_core_revision(
760        &self,
761        tool_name: &str,
762        args: &Value,
763        options: &InvokeOptions,
764        configured: Option<&crate::generated::types::DocPresetGetToolsParams>,
765    ) -> Result<Option<Value>> {
766        let Some(expected) = options.expected_revision.as_deref() else {
767            return Ok(None);
768        };
769        let params = serde_json::from_value(
770            serde_json::json!({"offset":crate::wire::Integer::MAX,"limit":1}),
771        )
772        .map_err(Error::decode)?;
773        let page = self.api().blocks().list(&params).await?;
774        if page.revision.is_empty() || page.revision == "unknown" {
775            let cli_reader = self.0.open_result.session_type == "local"
776                && self
777                    .0
778                    .client
779                    .0
780                    .transport
781                    .features()
782                    .contains("cli.invoke.localExecutionFacts");
783            return Err(if cli_reader {
784                Error::new(
785                    "HOST_PROTOCOL_ERROR",
786                    "CLI returned an invalid execution revision.",
787                )
788            } else {
789                Error::new(
790                    "EVIDENCE_UNAVAILABLE",
791                    "Required document revision is unavailable.",
792                )
793            });
794        }
795        if expected == page.revision {
796            return Ok(None);
797        }
798        if tool_name == "superdoc_perform_action" {
799            return crate::agent::stale_action_revision_receipt(args, &page.revision, configured)
800                .map(Some);
801        }
802        Err(Error::new(
803            "REVISION_CONFLICT",
804            crate::agent::stale_revision_message(tool_name),
805        ))
806    }
807
808    pub async fn dispatch_tool(
809        &self,
810        name: &str,
811        mut args: Value,
812        mut options: InvokeOptions,
813    ) -> Result<Value> {
814        self.active()?;
815        crate::agent::prepare_core_dispatch(name, &mut args, &mut options, None)?;
816        self.0.client.reject_unsupported_dispatch_preview(
817            "doc.preset.dispatch",
818            &options,
819            false,
820        )?;
821        if let Some(receipt) = self
822            .check_core_revision(name, &args, &options, None)
823            .await?
824        {
825            return Ok(json!({ "result": receipt }));
826        }
827        let _active = self.0.client.begin_call()?;
828        let transmission = Arc::new(CallState::default());
829
830        let result = self
831            .0
832            .client
833            .invoke_cli_tracked(
834                "doc.preset.dispatch",
835                json!({"sessionId":self.0.session_id,"toolName":name,"args":args,"preset":"core"}),
836                &options,
837                None,
838                Some(transmission),
839            )
840            .await;
841
842        result
843    }
844
845    fn active(&self) -> Result<()> {
846        match self.0.state.load(Ordering::Acquire) {
847            0 if !self.0.client.0.transport.is_closed() => Ok(()),
848            3 => Err(Error::new(
849                "DOCUMENT_STATE_UNKNOWN",
850                "document state is uncertain; use a new client to reopen verified saved content",
851            )),
852            _ => Err(Error::new(
853                "DOCUMENT_CLOSED",
854                "document handle is closed or closing",
855            )),
856        }
857    }
858}
859
860impl Invoker for Document {
861    fn handle_decode_error(&self, error: Error) -> Error {
862        if error.outcome_unknown {
863            let _ = self
864                .0
865                .state
866                .fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| {
867                    (state < 2).then_some(3)
868                });
869        }
870        error
871    }
872
873    async fn invoke(
874        &self,
875        id: &str,
876        mut params: Value,
877        mut options: InvokeOptions,
878    ) -> Result<Value> {
879        self.active().map_err(|mut error| {
880            if error.code == "DOCUMENT_CLOSED" && operation(id).is_ok() {
881                error.message =
882                    format!("Document handle is closed or closing; cannot invoke {id}.");
883                error.operation_id = Some(id.into());
884                error.details = Some(Box::new(json!({
885                    "sessionId": self.0.session_id,
886                    "operationId": id
887                })));
888            }
889            error
890        })?;
891        let _active = self.0.client.begin_call()?;
892        let operation = operation(id)?;
893        if operation["sdkSurface"] != "document" {
894            return Err(Error::new(
895                "INVALID_ARGUMENT",
896                "operation is not on the public document surface",
897            ));
898        }
899        self.0.client.reject_unsupported_dispatch_preview(
900            id,
901            &options,
902            params.get("preset") == Some(&json!("full")),
903        )?;
904        let binds_session = operation["params"]
905            .as_array()
906            .is_some_and(|params| params.iter().any(|param| param["name"] == "sessionId"));
907        if binds_session {
908            params["sessionId"] = json!(self.0.session_id);
909        }
910        let closing = id == "doc.close";
911        let transmission = closing.then(|| Arc::new(CallState::default()));
912        let _close = if closing {
913            self.0
914                .state
915                .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
916                .map_err(|_| Error::new("DOCUMENT_CLOSED", "document is already closing"))?;
917            Some(CloseGuard {
918                state: self.0.state.clone(),
919                transmission: transmission.clone().expect("close transmission state"),
920            })
921        } else {
922            None
923        };
924        if options.change_mode.is_none()
925            && operation["params"]
926                .as_array()
927                .is_some_and(|params| params.iter().any(|param| param["name"] == "changeMode"))
928        {
929            options.change_mode = self.0.client.0.options.default_change_mode.clone();
930        }
931        let result = self
932            .0
933            .client
934            .invoke_cli_tracked(id, params, &options, None, transmission)
935            .await
936            .and_then(|value| routing::map_domain_result(operation, value));
937        match result {
938            Ok(value) => {
939                if closing {
940                    if value["closed"] != true || value["contextId"] != self.0.session_id {
941                        let mut error = Error::new(
942                            "HOST_PROTOCOL_ERROR",
943                            "close was not confirmed by the host",
944                        );
945                        error.outcome_unknown = true;
946                        return Err(error);
947                    }
948                    self.0.state.store(2, Ordering::Release);
949                    self.0
950                        .client
951                        .0
952                        .transport
953                        .remove_session(&self.0.session_id, &self.0.state);
954                }
955                Ok(value)
956            }
957            Err(mut error) => {
958                error.operation_id = Some(id.into());
959                if closing && !error.outcome_unknown && !self.0.client.0.transport.is_closed() {
960                    let _ =
961                        self.0
962                            .state
963                            .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire);
964                }
965                Err(error)
966            }
967        }
968    }
969}
970
971/// Lag is explicit; callers must resynchronize rather than assume every remote
972/// change notification was observed. Session invalidation runs in the reader's
973/// owner independently of this public notification stream.
974pub struct Notifications(broadcast::Receiver<Value>);
975impl Notifications {
976    pub async fn recv(&mut self) -> Result<Value> {
977        self.0.recv().await.map_err(|error| match error {
978            broadcast::error::RecvError::Lagged(_) => Error::new(
979                "NOTIFICATIONS_LAGGED",
980                "resynchronize document state before using notification-derived caches",
981            ),
982            broadcast::error::RecvError::Closed => {
983                Error::new("HOST_DISCONNECTED", "notification stream closed")
984            }
985        })
986    }
987}