Skip to main content

tapes_client/core/
methods.rs

1//! Calling the sealed contract's operations over a transport.
2//!
3//! Every hand-written URL builder a client used to carry is one line here: the
4//! verb, the path template, and the parameter routing all come from
5//! `contracts/tapes-api.yaml`. A parameter the contract does not declare is
6//! refused before anything is sent, because a server that ignores an unknown
7//! query parameter would otherwise hide the drift a vendored contract exists to
8//! catch.
9//!
10//! # The typed surface is the default
11//!
12//! The named methods return the models in [`crate::core::models`], because the
13//! shape of a sealed response is not a consumer's opinion — it is published,
14//! vendored here, and held to the document by a build-time gate. A client that
15//! modelled it privately was keeping a second copy of a shared fact.
16//!
17//! The generic seam is still here, one layer down: [`CoreClient::call`] is
18//! generic in its response type and reaches every operation by `operationId`,
19//! including the ones no method below names. That is the **escape hatch**, and
20//! it is the right tool in two places — an operation this crate has not typed
21//! yet, and the fidelity reads where a typed decode would quietly truncate an
22//! archive of a newer server's data. It is not the default, and a call site
23//! that reaches for it should be able to say which of those two it is.
24//!
25//! The named methods remain conveniences over [`CoreClient::call`] and nothing
26//! more: the same operation table, the same routing, the same refusals. Anything
27//! else would be a second contract that can disagree with the first.
28
29use serde::Serialize;
30use serde::de::DeserializeOwned;
31use snafu::ResultExt;
32
33use crate::cassettes::discovery::Discovery;
34use crate::core::contract::{self, core, ops};
35use crate::core::models::params::ContractParams;
36use crate::core::models::{
37    CreateSkillRequest, ExportSessionParams, ExportSessionsParams, GenerateSkillRequest,
38    PublishSkillRequest, RawTurnListResponse, SearchSpansParams, SeedDemoRequest, SeedResult,
39    SessionDetailResponse, SessionItem, SessionListParams, SessionListResponse,
40    SessionSkillsResponse, SessionTracesParams, SessionTracesResponse, SessionUpdateRequest,
41    SkillResponse, SkillVersionResponse, SkillVersionsResponse, SkillsListParams,
42    SkillsListResponse, SpanItem, SpanSearchOutput, StatsParams, StatsResponse, TraceDetail,
43    TraceListParams, TraceListResponse, TraceParams, UpdateSkillRequest,
44};
45use crate::decode;
46use crate::error::{Result, error};
47use crate::page;
48use crate::transport::{StreamingTransport, TapesTransport, WireRequest};
49
50/// The sealed read surface, bound to one transport.
51#[derive(Debug, Clone, Copy)]
52pub struct CoreClient<T> {
53    transport: T,
54}
55
56impl<T> CoreClient<T> {
57    /// Bind the sealed surface to a transport.
58    #[must_use]
59    pub fn new(transport: T) -> Self {
60        Self { transport }
61    }
62
63    /// The transport this surface calls through.
64    #[must_use]
65    pub fn transport(&self) -> &T {
66        &self.transport
67    }
68
69    /// Take the transport back.
70    #[must_use]
71    pub fn into_transport(self) -> T {
72        self.transport
73    }
74}
75
76/// Send a sealed operation to the cassette that serves it now.
77///
78/// Three read surfaces were extracted from tapes core into cassettes —
79/// search, export, and skills — each serving its routes under
80/// `/v1/cassettes/<name>` with request and response shapes identical to the
81/// core routes they replace. Core's own copies are retirement-bound and no
82/// longer what deployments keep current, so every resolution of these
83/// operations is redirected here: the named methods, the generic
84/// [`CoreClient::call`] and [`CoreClient::stream`] escape hatches, and
85/// [`CoreClient::request_for`] all agree, because they all build their
86/// request through this.
87///
88/// Only the path moves — parameters, bodies, and models still come from the
89/// sealed contract, so a contract change to any of these shapes still lands
90/// at vendor time. `listSessionSkills` is the one reshape: core spelled it
91/// `GET /v1/sessions/{id}/skills`, and the skills cassette serves the same
92/// listing as a `session_id` filter on its own collection, so the path
93/// parameter becomes a query parameter.
94///
95/// A deployment that does not serve the cassette answers 404 where core once
96/// answered; typed access over the *discovered* surface, which would make
97/// that a first-class "not served here", is the successor to this table.
98fn reroute_to_cassette(operation_id: &str, request: &mut WireRequest<'static>) {
99    request.path = match operation_id {
100        ops::SEARCH_SPANS => "/v1/cassettes/search/spans",
101        ops::EXPORT_SESSION => "/v1/cassettes/export/sessions/{id}",
102        ops::EXPORT_SESSIONS => "/v1/cassettes/export/sessions",
103        ops::LIST_SKILLS | ops::CREATE_SKILL => "/v1/cassettes/skills",
104        ops::GET_SKILL | ops::UPDATE_SKILL | ops::DELETE_SKILL => "/v1/cassettes/skills/{id}",
105        ops::DUPLICATE_SKILL => "/v1/cassettes/skills/{id}/duplicate",
106        ops::GET_SKILL_MARKDOWN => "/v1/cassettes/skills/{id}/skill.md",
107        ops::LIST_SKILL_VERSIONS | ops::PUBLISH_SKILL => "/v1/cassettes/skills/{id}/versions",
108        ops::GENERATE_SKILL => "/v1/cassettes/skills/generate",
109        ops::LIST_SESSION_SKILLS => {
110            let session = request
111                .path_params
112                .iter()
113                .position(|(name, _)| name == "id")
114                .map(|index| request.path_params.remove(index));
115            if let Some((_, id)) = session {
116                request.query.push(("session_id".to_owned(), id));
117            }
118            "/v1/cassettes/skills"
119        }
120        _ => return,
121    };
122}
123
124impl<T: TapesTransport> CoreClient<T> {
125    /// Resolve one operation in the sealed contract and call it, decoding into
126    /// a type the caller names.
127    ///
128    /// The escape hatch — see the module docs. Equivalent to
129    /// [`CoreClient::call_with_body`] with no body, which is what every read
130    /// operation wants. An operation whose `requestBody` the contract marks
131    /// required is refused rather than sent without one.
132    ///
133    /// # Errors
134    ///
135    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
136    pub async fn call<R: DeserializeOwned>(
137        &self,
138        operation_id: &str,
139        values: Vec<(&str, String)>,
140    ) -> Result<R> {
141        self.call_with_body(operation_id, values, None).await
142    }
143
144    /// Resolve one operation and call it with a request body.
145    ///
146    /// The body travels the same route as every other value: the contract
147    /// decides whether the operation accepts one, requires one, or takes none,
148    /// and a disagreement in either direction is refused before anything is
149    /// sent. Without this the capability would exist one layer down and be
150    /// unreachable from the facade callers actually use — which is exactly
151    /// where a payload goes missing quietly.
152    ///
153    /// # Errors
154    ///
155    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
156    pub async fn call_with_body<R: DeserializeOwned>(
157        &self,
158        operation_id: &str,
159        values: Vec<(&str, String)>,
160        body: Option<String>,
161    ) -> Result<R> {
162        let method = core()?.method(operation_id)?;
163        let mut request = contract::call_for_with_body(method, values, body)?;
164        reroute_to_cassette(operation_id, &mut request);
165        let response = self
166            .transport
167            .send(&request)
168            .await
169            .context(error::TransportSnafu)?;
170        decode::json_typed(&response)
171    }
172
173    /// Build the request for one operation without sending it.
174    ///
175    /// For a caller that needs to inspect or decorate a request — a page walk
176    /// setting cursors, a test asserting a URL — without a second route to the
177    /// wire that could route values differently.
178    ///
179    /// # Errors
180    ///
181    /// Any contract failure; see [`crate::Error`].
182    pub fn request_for(
183        &self,
184        operation_id: &str,
185        values: Vec<(&str, String)>,
186    ) -> Result<WireRequest<'static>> {
187        let mut request = contract::call_for(core()?.method(operation_id)?, values)?;
188        reroute_to_cassette(operation_id, &mut request);
189        Ok(request)
190    }
191
192    /// Call an operation with a typed parameter set.
193    async fn with_params<P: ContractParams, R: DeserializeOwned>(&self, params: &P) -> Result<R> {
194        self.call(P::OPERATION, params.values()).await
195    }
196
197    /// Call an operation with a typed parameter set and a path value.
198    async fn with_params_at<P: ContractParams, R: DeserializeOwned>(
199        &self,
200        params: &P,
201        path: Vec<(&str, String)>,
202    ) -> Result<R> {
203        let mut values: Vec<(&str, String)> = params.values();
204        values.extend(path);
205        self.call(P::OPERATION, values).await
206    }
207
208    /// Call an operation with a typed request body.
209    async fn with_body<B: Serialize, R: DeserializeOwned>(
210        &self,
211        operation_id: &str,
212        values: Vec<(&str, String)>,
213        body: &B,
214    ) -> Result<R> {
215        let rendered = serde_json::to_string(body).context(error::RenderBodySnafu)?;
216        self.call_with_body(operation_id, values, Some(rendered))
217            .await
218    }
219
220    /// `GET /v1/sessions` — one page of the sessions listing.
221    ///
222    /// # Errors
223    ///
224    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
225    pub async fn list_sessions(&self, params: &SessionListParams) -> Result<SessionListResponse> {
226        self.with_params(params).await
227    }
228
229    /// Every session the listing matches, following `next_cursor` to the end.
230    ///
231    /// The cursor convention is [`crate::page`]'s, so this walk and a cassette
232    /// listing's stop on the same three spellings of "no more pages" and share
233    /// the guard against a server that repeats a cursor.
234    ///
235    /// # Errors
236    ///
237    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
238    pub async fn list_all_sessions(&self, params: &SessionListParams) -> Result<Vec<SessionItem>> {
239        page::walk(|cursor| {
240            let mut params = params.clone();
241            params.cursor = cursor;
242            async move { Ok(self.list_sessions(&params).await?.into_page()) }
243        })
244        .await
245    }
246
247    /// `GET /v1/sessions/{id}` — one session record.
248    ///
249    /// # Errors
250    ///
251    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
252    pub async fn get_session(&self, id: &str) -> Result<SessionDetailResponse> {
253        self.call(ops::GET_SESSION, vec![("id", id.to_owned())])
254            .await
255    }
256
257    /// `PATCH /v1/sessions/{id}` — rename a session.
258    ///
259    /// # Errors
260    ///
261    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
262    pub async fn update_session(
263        &self,
264        id: &str,
265        body: &SessionUpdateRequest,
266    ) -> Result<SessionDetailResponse> {
267        self.with_body(ops::UPDATE_SESSION, vec![("id", id.to_owned())], body)
268            .await
269    }
270
271    /// `DELETE /v1/sessions/{id}` — delete a session and its subtree.
272    ///
273    /// # Errors
274    ///
275    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
276    pub async fn delete_session(&self, id: &str) -> Result<()> {
277        self.call(ops::DELETE_SESSION, vec![("id", id.to_owned())])
278            .await
279    }
280
281    /// `GET /v1/sessions/{id}/traces` — the derived span read model.
282    ///
283    /// # Errors
284    ///
285    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
286    pub async fn get_session_traces(
287        &self,
288        id: &str,
289        params: &SessionTracesParams,
290    ) -> Result<SessionTracesResponse> {
291        self.with_params_at(params, vec![("id", id.to_owned())])
292            .await
293    }
294
295    /// `GET /v1/sessions/{id}/raw_turns` — the wire log behind a derivation.
296    ///
297    /// # Errors
298    ///
299    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
300    pub async fn list_raw_turns(&self, id: &str) -> Result<RawTurnListResponse> {
301        self.call(ops::LIST_RAW_TURNS, vec![("id", id.to_owned())])
302            .await
303    }
304
305    /// `GET /v1/cassettes/skills?session_id={id}` — the skills attributed to one
306    /// session, as the skills cassette spells it (see [`reroute_to_cassette`]).
307    ///
308    /// # Errors
309    ///
310    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
311    pub async fn list_session_skills(&self, id: &str) -> Result<SessionSkillsResponse> {
312        self.call(ops::LIST_SESSION_SKILLS, vec![("id", id.to_owned())])
313            .await
314    }
315
316    /// `GET /v1/traces` — the trace summaries for one session.
317    ///
318    /// # Errors
319    ///
320    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
321    pub async fn list_traces(&self, params: &TraceListParams) -> Result<TraceListResponse> {
322        self.with_params(params).await
323    }
324
325    /// `GET /v1/traces/{trace_id}` — one trace with its spans.
326    ///
327    /// # Errors
328    ///
329    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
330    pub async fn get_trace(&self, trace_id: &str, params: &TraceParams) -> Result<TraceDetail> {
331        self.with_params_at(params, vec![("trace_id", trace_id.to_owned())])
332            .await
333    }
334
335    /// `GET /v1/traces/{trace_id}/spans/{span_id}` — one span, in full.
336    ///
337    /// # Errors
338    ///
339    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
340    pub async fn get_span(&self, trace_id: &str, span_id: &str) -> Result<SpanItem> {
341        self.call(
342            ops::GET_SPAN,
343            vec![
344                ("trace_id", trace_id.to_owned()),
345                ("span_id", span_id.to_owned()),
346            ],
347        )
348        .await
349    }
350
351    /// `GET /v1/cassettes/search/spans` — semantic search over span
352    /// embeddings, served by the search cassette (see
353    /// [`reroute_to_cassette`]).
354    ///
355    /// # Errors
356    ///
357    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
358    pub async fn search_spans(&self, params: &SearchSpansParams) -> Result<SpanSearchOutput> {
359        self.with_params(params).await
360    }
361
362    /// `GET /v1/stats` — the aggregate rollups.
363    ///
364    /// # Errors
365    ///
366    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
367    pub async fn get_stats(&self, params: &StatsParams) -> Result<StatsResponse> {
368        self.with_params(params).await
369    }
370
371    /// `GET /v1/cassettes/skills` — one page of the skills listing.
372    ///
373    /// # Errors
374    ///
375    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
376    pub async fn list_skills(&self, params: &SkillsListParams) -> Result<SkillsListResponse> {
377        self.with_params(params).await
378    }
379
380    /// Every skill the listing matches, following `next_cursor` to the end.
381    ///
382    /// # Errors
383    ///
384    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
385    pub async fn list_all_skills(&self, params: &SkillsListParams) -> Result<Vec<SkillResponse>> {
386        page::walk(|cursor| {
387            let mut params = params.clone();
388            params.cursor = cursor;
389            async move { Ok(self.list_skills(&params).await?.into_page()) }
390        })
391        .await
392    }
393
394    /// `GET /v1/cassettes/skills/{id}` — one skill.
395    ///
396    /// # Errors
397    ///
398    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
399    pub async fn get_skill(&self, id: &str) -> Result<SkillResponse> {
400        self.call(ops::GET_SKILL, vec![("id", id.to_owned())]).await
401    }
402
403    /// `POST /v1/cassettes/skills` — author a skill.
404    ///
405    /// # Errors
406    ///
407    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
408    pub async fn create_skill(&self, body: &CreateSkillRequest) -> Result<SkillResponse> {
409        self.with_body(ops::CREATE_SKILL, Vec::new(), body).await
410    }
411
412    /// `PUT /v1/cassettes/skills/{id}` — apply the present fields onto a skill.
413    ///
414    /// # Errors
415    ///
416    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
417    pub async fn update_skill(&self, id: &str, body: &UpdateSkillRequest) -> Result<SkillResponse> {
418        self.with_body(ops::UPDATE_SKILL, vec![("id", id.to_owned())], body)
419            .await
420    }
421
422    /// `DELETE /v1/cassettes/skills/{id}` — delete a skill.
423    ///
424    /// # Errors
425    ///
426    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
427    pub async fn delete_skill(&self, id: &str) -> Result<()> {
428        self.call(ops::DELETE_SKILL, vec![("id", id.to_owned())])
429            .await
430    }
431
432    /// `POST /v1/cassettes/skills/{id}/duplicate` — fork a skill.
433    ///
434    /// # Errors
435    ///
436    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
437    pub async fn duplicate_skill(&self, id: &str) -> Result<SkillResponse> {
438        self.call(ops::DUPLICATE_SKILL, vec![("id", id.to_owned())])
439            .await
440    }
441
442    /// `GET /v1/cassettes/skills/{id}/versions` — one skill's published history.
443    ///
444    /// # Errors
445    ///
446    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
447    pub async fn list_skill_versions(&self, id: &str) -> Result<SkillVersionsResponse> {
448        self.call(ops::LIST_SKILL_VERSIONS, vec![("id", id.to_owned())])
449            .await
450    }
451
452    /// `POST /v1/cassettes/skills/{id}/versions` — publish an immutable snapshot.
453    ///
454    /// # Errors
455    ///
456    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
457    pub async fn publish_skill(
458        &self,
459        id: &str,
460        body: &PublishSkillRequest,
461    ) -> Result<SkillVersionResponse> {
462        self.with_body(ops::PUBLISH_SKILL, vec![("id", id.to_owned())], body)
463            .await
464    }
465
466    /// `POST /v1/cassettes/skills/generate` — generate a skill from sessions.
467    ///
468    /// # Errors
469    ///
470    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
471    pub async fn generate_skill(&self, body: &GenerateSkillRequest) -> Result<SkillResponse> {
472        self.with_body(ops::GENERATE_SKILL, Vec::new(), body).await
473    }
474
475    /// `GET /v1/cassettes` — what this deployment serves.
476    ///
477    /// Decodes into the cassette surface's own model rather than a second copy
478    /// of it: [`crate::cassettes::discovery`] reads the fields the generated
479    /// command surface acts on, and modelling the document twice is the
480    /// duplication this crate exists to end.
481    ///
482    /// # Errors
483    ///
484    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
485    pub async fn list_cassettes(&self) -> Result<Discovery> {
486        self.call(ops::LIST_CASSETTES, Vec::new()).await
487    }
488
489    /// `POST /v1/admin/seed/demo` — replay the demo corpora.
490    ///
491    /// # Errors
492    ///
493    /// Any contract, transport, status, or decode failure; see [`crate::Error`].
494    pub async fn seed_demo(&self, body: &SeedDemoRequest) -> Result<SeedResult> {
495        self.with_body(ops::SEED_DEMO, Vec::new(), body).await
496    }
497}
498
499impl<T: StreamingTransport> CoreClient<T> {
500    /// Resolve one operation and stream its response.
501    ///
502    /// Bodyless, and deliberately so: nothing in this contract both streams a
503    /// response and takes a request body. That is an observation about the
504    /// document rather than a rule, so it is not enforced here — an operation
505    /// that did take a required body would be refused with the same loud error
506    /// as anywhere else, which is a signal to add the body-bearing sibling
507    /// rather than a payload going missing.
508    ///
509    /// # Errors
510    ///
511    /// Any contract or transport failure; see [`crate::Error`].
512    pub async fn stream(&self, operation_id: &str, values: Vec<(&str, String)>) -> Result<T::Body> {
513        let method = core()?.method(operation_id)?;
514        let mut request = contract::call_for(method, values)?;
515        reroute_to_cassette(operation_id, &mut request);
516        self.transport.send_stream(&request).await
517    }
518
519    /// `GET /v1/cassettes/export/sessions/{id}`, streamed.
520    ///
521    /// An export can be far larger than a session's working set, and there is
522    /// no reason to hold it in memory on the way to a file. It stays untyped
523    /// for the same reason: an archive written through a typed decode is an
524    /// archive of the fields this build happened to know about.
525    ///
526    /// # Errors
527    ///
528    /// Any contract or transport failure; see [`crate::Error`].
529    pub async fn export_session(&self, id: &str, params: &ExportSessionParams) -> Result<T::Body> {
530        let mut values = params.values();
531        values.push(("id", id.to_owned()));
532        self.stream(ops::EXPORT_SESSION, values).await
533    }
534
535    /// `GET /v1/cassettes/export/sessions`, streamed.
536    ///
537    /// # Errors
538    ///
539    /// Any contract or transport failure; see [`crate::Error`].
540    pub async fn export_sessions(&self, params: &ExportSessionsParams) -> Result<T::Body> {
541        self.stream(ops::EXPORT_SESSIONS, params.values()).await
542    }
543}
544
545#[cfg(test)]
546#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
547mod tests {
548    use super::*;
549    use crate::cassettes::spec::Location;
550    use crate::core::models::params::PayloadDetail;
551    use crate::path::{PathMode, call_url};
552    use crate::transport::{TransportError, WireResponse};
553    use serde::Deserialize;
554    use serde_json::Value;
555    use std::cell::RefCell;
556    use url::Url;
557
558    /// A transport that records what it was asked to send and answers with a
559    /// canned response — enough to prove the contract layer routed the values,
560    /// without a socket.
561    ///
562    /// It records the request body as well as the URL, because "the payload
563    /// arrived at the transport" is the only place a facade that dropped it
564    /// would be visible: every layer above still looks correct.
565    struct Recorder {
566        base: Url,
567        responses: RefCell<Vec<Value>>,
568        seen: RefCell<Vec<String>>,
569        bodies: RefCell<Vec<Option<String>>>,
570    }
571
572    impl Recorder {
573        fn new(base: &str, responses: Vec<Value>) -> Self {
574            Self {
575                base: Url::parse(base).unwrap(),
576                responses: RefCell::new(responses),
577                seen: RefCell::new(Vec::new()),
578                bodies: RefCell::new(Vec::new()),
579            }
580        }
581    }
582
583    impl TapesTransport for Recorder {
584        async fn send(
585            &self,
586            request: &WireRequest<'_>,
587        ) -> std::result::Result<WireResponse, TransportError> {
588            let url = call_url(&self.base, request, PathMode::UnderBase)
589                .map_err(|error| TransportError::new(error.to_string()))?;
590            self.seen.borrow_mut().push(url.to_string());
591            self.bodies.borrow_mut().push(request.body.clone());
592            let mut responses = self.responses.borrow_mut();
593            let body = if responses.len() > 1 {
594                responses.remove(0)
595            } else {
596                responses.first().cloned().unwrap_or(Value::Null)
597            };
598            Ok(WireResponse::new(
599                200,
600                url.to_string(),
601                Vec::new(),
602                body.to_string().into_bytes(),
603            ))
604        }
605    }
606
607    fn client(base: &str, response: Value) -> CoreClient<Recorder> {
608        CoreClient::new(Recorder::new(base, vec![response]))
609    }
610
611    #[tokio::test]
612    async fn an_operation_is_routed_through_the_contract_and_the_transport() {
613        let client = client(
614            "https://acme.example/primary/tapes/",
615            serde_json::json!({"traces": []}),
616        );
617        let _ = client
618            .get_session_traces("s-1", &SessionTracesParams::default())
619            .await
620            .unwrap();
621
622        assert_eq!(
623            client.transport().seen.borrow()[0],
624            "https://acme.example/primary/tapes/v1/sessions/s-1/traces",
625        );
626    }
627
628    #[tokio::test]
629    async fn a_typed_method_decodes_the_contracts_own_shape() {
630        // The default surface: the caller names no type, and the fields it
631        // reads are the ones the sealed document publishes.
632        let client = client(
633            "http://127.0.0.1:8081",
634            serde_json::json!({
635                "items": [{"id": "s1", "rollup": {"turn_count": 3}}],
636                "next_cursor": "abc",
637            }),
638        );
639        let listing = client
640            .list_sessions(&SessionListParams::default())
641            .await
642            .unwrap();
643
644        assert_eq!(listing.items[0].id, "s1");
645        assert_eq!(listing.items[0].rollup.turn_count, 3);
646        assert_eq!(listing.next_cursor, "abc");
647    }
648
649    #[tokio::test]
650    async fn a_typed_method_survives_a_field_it_has_never_heard_of() {
651        // The rule the models are built on, exercised end to end: a newer
652        // server is not a malformed response.
653        let client = client(
654            "http://127.0.0.1:8081",
655            serde_json::json!({"items": [{"id": "s1", "a_field_from_the_future": 7}]}),
656        );
657        let listing = client
658            .list_sessions(&SessionListParams::default())
659            .await
660            .unwrap();
661        assert_eq!(listing.items[0].id, "s1");
662    }
663
664    #[tokio::test]
665    async fn the_generic_seam_still_decodes_into_a_callers_own_type() {
666        // The escape hatch stays reachable, and stays untyped when a caller
667        // asks for a document rather than a model.
668        #[derive(Debug, Deserialize)]
669        struct Listing {
670            next_cursor: String,
671        }
672
673        let client = client(
674            "http://127.0.0.1:8081",
675            serde_json::json!({"items": [], "next_cursor": "abc"}),
676        );
677        let got: Listing = client.call(ops::LIST_SESSIONS, Vec::new()).await.unwrap();
678        assert_eq!(got.next_cursor, "abc");
679
680        let raw: Value = client.call(ops::LIST_SESSIONS, Vec::new()).await.unwrap();
681        assert_eq!(raw["next_cursor"], "abc");
682    }
683
684    #[tokio::test]
685    async fn a_typed_parameter_travels_under_the_contracts_own_name() {
686        let client = client("http://127.0.0.1:8081", serde_json::json!({"traces": []}));
687        let _ = client
688            .get_session_traces(
689                "s-1",
690                &SessionTracesParams {
691                    payload: Some(PayloadDetail::Preview),
692                },
693            )
694            .await
695            .unwrap();
696        assert!(
697            client.transport().seen.borrow()[0].ends_with("/traces?payload=preview"),
698            "got: {:?}",
699            client.transport().seen.borrow(),
700        );
701    }
702
703    #[tokio::test]
704    async fn a_listing_walk_follows_the_cursor_to_the_end() {
705        // The models and the crate's one pagination convention meet here: the
706        // envelope becomes a `Page`, and `page::walk` owns the loop.
707        let client = CoreClient::new(Recorder::new(
708            "http://127.0.0.1:8081",
709            vec![
710                serde_json::json!({"items": [{"id": "s1"}], "next_cursor": "c1"}),
711                serde_json::json!({"items": [{"id": "s2"}], "next_cursor": ""}),
712            ],
713        ));
714        let sessions = client
715            .list_all_sessions(&SessionListParams::default())
716            .await
717            .unwrap();
718
719        assert_eq!(
720            sessions.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
721            vec!["s1", "s2"],
722        );
723        assert!(
724            client.transport().seen.borrow()[1].contains("cursor=c1"),
725            "got: {:?}",
726            client.transport().seen.borrow(),
727        );
728    }
729
730    #[tokio::test]
731    async fn an_undeclared_parameter_is_refused_before_the_transport_is_reached() {
732        let client = client("http://127.0.0.1:8081", Value::Null);
733        let err = client
734            .call::<Value>(ops::GET_SESSION, vec![("payolad", "full".to_owned())])
735            .await
736            .unwrap_err();
737        assert!(err.to_string().contains("payolad"), "got: {err}");
738        assert!(
739            client.transport().seen.borrow().is_empty(),
740            "nothing may be sent for a call the contract refused",
741        );
742    }
743
744    #[tokio::test]
745    async fn a_typed_body_reaches_the_transport_as_the_contracts_own_json() {
746        // The gap this closes: the body capability exists one layer down, and
747        // a facade that routed around it would drop a payload passed here
748        // while still producing a request that looked correct.
749        let client = client("http://127.0.0.1:8081", serde_json::json!({"id": "sk-1"}));
750        let skill = client
751            .create_skill(&CreateSkillRequest {
752                name: "gum".to_owned(),
753                ..Default::default()
754            })
755            .await
756            .unwrap();
757
758        assert_eq!(skill.id, "sk-1");
759        let bodies = client.transport().bodies.borrow();
760        let sent: Value = serde_json::from_str(bodies[0].as_deref().unwrap()).unwrap();
761        assert_eq!(sent["name"], "gum");
762    }
763
764    #[tokio::test]
765    async fn the_bodyless_facade_refuses_an_operation_that_requires_a_body() {
766        // The whole point of the refusal is that it survives every route to
767        // the wire; a facade that quietly sent the request anyway would be the
768        // original silence with an extra layer on top.
769        let client = client("http://127.0.0.1:8081", Value::Null);
770        let err = client
771            .call::<Value>(ops::CREATE_SKILL, Vec::new())
772            .await
773            .unwrap_err();
774
775        assert!(
776            err.to_string().contains("requires a request body"),
777            "got: {err}",
778        );
779        assert!(client.transport().seen.borrow().is_empty());
780    }
781
782    #[tokio::test]
783    async fn the_facade_refuses_a_body_on_an_operation_that_declares_none() {
784        let client = client("http://127.0.0.1:8081", Value::Null);
785        let err = client
786            .call_with_body::<Value>(
787                ops::GET_SESSION,
788                vec![("id", "s-1".to_owned())],
789                Some("{}".to_owned()),
790            )
791            .await
792            .unwrap_err();
793
794        assert!(
795            err.to_string().contains("declares no request body"),
796            "got: {err}",
797        );
798        assert!(client.transport().seen.borrow().is_empty());
799    }
800
801    #[tokio::test]
802    async fn the_bodyless_facade_still_sends_no_body_for_an_ordinary_read() {
803        // Plumbing a body through must not start attaching one where none was
804        // asked for: every read operation goes out exactly as before.
805        let client = client("http://127.0.0.1:8081", serde_json::json!({"items": []}));
806        let _ = client
807            .list_sessions(&SessionListParams::default())
808            .await
809            .unwrap();
810        assert_eq!(client.transport().bodies.borrow().as_slice(), [None]);
811    }
812
813    impl crate::transport::StreamingTransport for Recorder {
814        type Body = Vec<u8>;
815
816        async fn send_stream(&self, request: &WireRequest<'_>) -> Result<Self::Body> {
817            let url = call_url(&self.base, request, PathMode::UnderBase).map_err(|error| {
818                crate::Error::Transport {
819                    source: TransportError::new(error.to_string()),
820                }
821            })?;
822            self.seen.borrow_mut().push(url.to_string());
823            Ok(Vec::new())
824        }
825    }
826
827    /// Every operation whose surface moved into a cassette, with the route
828    /// the request must now target. One table, asserted through the same
829    /// request-building path every caller uses — a route that reads
830    /// `/v1/skills` or `/v1/sessions/{id}/export` again is a client that
831    /// silently moved back to a retirement-bound core copy.
832    #[tokio::test]
833    async fn extracted_operations_target_their_cassette_routes() {
834        let client = CoreClient::new(Recorder::new(
835            "http://127.0.0.1:8081",
836            vec![serde_json::json!({})],
837        ));
838        type Case = (&'static str, Vec<(&'static str, String)>, &'static str);
839        let id = ("id", "x-1".to_owned());
840        let cases: &[Case] = &[
841            (
842                ops::SEARCH_SPANS,
843                vec![("query", "q".to_owned())],
844                "/v1/cassettes/search/spans",
845            ),
846            (
847                ops::EXPORT_SESSION,
848                vec![id.clone()],
849                "/v1/cassettes/export/sessions/x-1",
850            ),
851            (
852                ops::EXPORT_SESSIONS,
853                vec![],
854                "/v1/cassettes/export/sessions",
855            ),
856            (ops::LIST_SKILLS, vec![], "/v1/cassettes/skills"),
857            (ops::GET_SKILL, vec![id.clone()], "/v1/cassettes/skills/x-1"),
858            (
859                ops::DELETE_SKILL,
860                vec![id.clone()],
861                "/v1/cassettes/skills/x-1",
862            ),
863            (
864                ops::DUPLICATE_SKILL,
865                vec![id.clone()],
866                "/v1/cassettes/skills/x-1/duplicate",
867            ),
868            (
869                ops::GET_SKILL_MARKDOWN,
870                vec![id.clone()],
871                "/v1/cassettes/skills/x-1/skill.md",
872            ),
873            (
874                ops::LIST_SKILL_VERSIONS,
875                vec![id.clone()],
876                "/v1/cassettes/skills/x-1/versions",
877            ),
878        ];
879        for (operation, values, expected) in cases {
880            let request = client
881                .request_for(operation, values.clone())
882                .unwrap_or_else(|e| panic!("{operation}: {e}"));
883            let url = call_url(
884                &Url::parse("http://127.0.0.1:8081").unwrap(),
885                &request,
886                PathMode::UnderBase,
887            )
888            .unwrap_or_else(|e| panic!("{operation}: {e}"));
889            assert!(
890                url.path().ends_with(expected.trim_start_matches('/')) || url.path() == *expected,
891                "{operation}: expected {expected}, got {}",
892                url.path()
893            );
894            assert!(
895                !url.path().contains("/v1/skills")
896                    && !url.path().contains("/v1/search")
897                    && !url.path().contains("/v1/sessions"),
898                "{operation}: still targets a core route: {}",
899                url.path()
900            );
901        }
902
903        // The body-bearing operations cannot be built through request_for
904        // (it refuses a required body), so they are asserted through the
905        // same call path a consumer uses.
906        let _: std::result::Result<Value, _> = client
907            .call_with_body(ops::GENERATE_SKILL, Vec::new(), Some("{}".to_owned()))
908            .await;
909        let _: std::result::Result<Value, _> = client
910            .call_with_body(ops::CREATE_SKILL, Vec::new(), Some("{}".to_owned()))
911            .await;
912        let _: std::result::Result<Value, _> = client
913            .call_with_body(
914                ops::PUBLISH_SKILL,
915                vec![("id", "x-1".to_owned())],
916                Some("{}".to_owned()),
917            )
918            .await;
919        let seen = client.transport().seen.borrow();
920        let tail: Vec<&String> = seen.iter().rev().take(3).collect();
921        assert!(
922            tail[2].contains("/v1/cassettes/skills/generate"),
923            "got {}",
924            tail[2]
925        );
926        assert!(tail[1].ends_with("/v1/cassettes/skills"), "got {}", tail[1]);
927        assert!(
928            tail[0].contains("/v1/cassettes/skills/x-1/versions"),
929            "got {}",
930            tail[0]
931        );
932    }
933
934    #[test]
935    fn session_skills_becomes_a_session_id_filter_on_the_skills_cassette() {
936        // The one reshape in the table: core's per-session skills listing is
937        // the cassette collection filtered by session_id, so the path
938        // parameter must travel as a query parameter — dropping it instead
939        // would silently widen the listing to every skill.
940        let client = CoreClient::new(Recorder::new(
941            "http://127.0.0.1:8081",
942            vec![serde_json::json!({})],
943        ));
944        let request = client
945            .request_for(ops::LIST_SESSION_SKILLS, vec![("id", "ses-9".to_owned())])
946            .unwrap();
947        let url = call_url(
948            &Url::parse("http://127.0.0.1:8081").unwrap(),
949            &request,
950            PathMode::UnderBase,
951        )
952        .unwrap();
953        assert!(
954            url.path().ends_with("/v1/cassettes/skills"),
955            "got {}",
956            url.path()
957        );
958        assert!(
959            url.query_pairs()
960                .any(|(k, v)| k == "session_id" && v == "ses-9"),
961            "session_id must survive as a query parameter, got {:?}",
962            url.query()
963        );
964    }
965
966    #[tokio::test]
967    async fn the_stream_escape_hatch_reroutes_like_the_typed_surface() {
968        // paperctl streams skill.md through the generic operation-id seam;
969        // the reroute must live below that seam, not only in named methods.
970        let client = CoreClient::new(Recorder::new(
971            "http://127.0.0.1:8081",
972            vec![serde_json::json!({})],
973        ));
974        let _ = client
975            .stream(ops::GET_SKILL_MARKDOWN, vec![("id", "skl-1".to_owned())])
976            .await
977            .unwrap();
978        let seen = client.transport().seen.borrow();
979        assert!(
980            seen[0].contains("/v1/cassettes/skills/skl-1/skill.md"),
981            "got {}",
982            seen[0]
983        );
984    }
985
986    #[test]
987    fn every_operation_on_an_extracted_route_is_rerouted() {
988        // The completeness gate over the table above, driven by the vendored
989        // contract itself: every operation whose sealed path lives on a
990        // surface that moved into a cassette must be rerouted. A contract
991        // refresh that adds an operation under /v1/skills (or a new extracted
992        // surface's routes) fails here until the table routes it — reaching
993        // core's retirement-bound copy silently is exactly the drift this
994        // gate exists to end.
995        let surface = core().unwrap();
996        let extracted = |path: &str| {
997            path.starts_with("/v1/search")
998                || path.starts_with("/v1/skills")
999                || path == "/v1/sessions/export"
1000                || path == "/v1/sessions/{id}/export"
1001                || path == "/v1/sessions/{id}/skills"
1002        };
1003
1004        let ids: Vec<&str> = surface.operation_ids().collect();
1005        let mut checked = 0;
1006        for id in ids {
1007            let method = surface.method(id).unwrap();
1008            if !extracted(&method.path) {
1009                continue;
1010            }
1011            checked += 1;
1012
1013            let values: Vec<(&str, String)> = method
1014                .params
1015                .iter()
1016                .filter(|param| param.required || matches!(param.location, Location::Path))
1017                .map(|param| (param.wire.as_str(), "x".to_owned()))
1018                .collect();
1019            let body = (method.body == Some(true)).then(|| "{}".to_owned());
1020            let mut request = contract::call_for_with_body(method, values, body)
1021                .unwrap_or_else(|error| panic!("{id}: {error}"));
1022            reroute_to_cassette(id, &mut request);
1023            assert!(
1024                request.path.starts_with("/v1/cassettes/"),
1025                "{id} still targets {} — add it to reroute_to_cassette",
1026                request.path
1027            );
1028        }
1029        assert_eq!(
1030            checked, 14,
1031            "the census of operations on extracted routes moved; route the newcomer above and update this count"
1032        );
1033    }
1034
1035    #[tokio::test]
1036    async fn search_spans_targets_the_search_cassette_route() {
1037        // The one deliberate departure from the operation table: span search
1038        // is served by the search cassette, and the sealed operation only
1039        // supplies the parameter plumbing. If this URL ever reads
1040        // /v1/search/spans again, the client has silently moved back to the
1041        // retirement-bound core route.
1042        let client = client(
1043            "http://127.0.0.1:8081",
1044            serde_json::json!({"query": "q", "results": []}),
1045        );
1046        let _ = client
1047            .search_spans(&SearchSpansParams {
1048                query: "retry backoff".to_owned(),
1049                top_k: Some(3),
1050            })
1051            .await
1052            .unwrap();
1053
1054        let seen = client.transport().seen.borrow();
1055        assert_eq!(seen.len(), 1);
1056        assert!(
1057            seen[0].contains("/v1/cassettes/search/spans?"),
1058            "expected the cassette route, got {}",
1059            seen[0]
1060        );
1061        assert!(
1062            seen[0].contains("query=retry+backoff") || seen[0].contains("query=retry%20backoff")
1063        );
1064        assert!(seen[0].contains("top_k=3"));
1065    }
1066
1067    #[tokio::test]
1068    async fn a_named_method_and_its_operation_id_build_the_same_request() {
1069        // The named methods must stay conveniences. If one ever routed a value
1070        // differently from the operation id it names, this crate would be back
1071        // to two ways of building a request that can disagree.
1072        let named = client("http://127.0.0.1:8081", serde_json::json!({}));
1073        let _ = named.get_span("t-1", "sp-1").await.unwrap();
1074
1075        let raw = client("http://127.0.0.1:8081", serde_json::json!({}));
1076        let _: Value = raw
1077            .call(
1078                ops::GET_SPAN,
1079                vec![
1080                    ("trace_id", "t-1".to_owned()),
1081                    ("span_id", "sp-1".to_owned()),
1082                ],
1083            )
1084            .await
1085            .unwrap();
1086
1087        assert_eq!(
1088            *named.transport().seen.borrow(),
1089            *raw.transport().seen.borrow()
1090        );
1091    }
1092}