Closes docs/RUST.md's "client-core prerequisites for P1" box: the cache-vs-server stitching TranscriptSource.kt does, and the joinPages/healSplitMessage/adoptRun page-boundary healing TranscriptItems.kt does, both ported into client-core with no UI framework dependency. Neither Kotlin file had a JVM unit test of its own, so the port used the Kotlin source and AGENTS.md's "things that have bitten" paging incidents as the spec instead of a test-for-test transcription. Both regressions get a dedicated test: TranscriptSource::page refuses before == 0 before touching the cache or the network (loadOlderPage's incident), and adopt_run now runs on every page join rather than only the one where a split call was found (the "one run drawn as two" incident). fetch_transcript_lines (api.rs, additive) pairs each transcript line with the exact server bytes via serde_json::value::RawValue rather than re-serializing a parsed Value, so a cached line and a live SSE frame for the same event agree byte-for-byte -- the fetch_transcript_page other callers under iris/ depend on is untouched. client-core: 85 -> 109 tests. cargo test/clippy --all-targets/fmt clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
610 lines
21 KiB
Rust
610 lines
21 KiB
Rust
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
|
|
//! module doc for the table); the SSE half is [`crate::event_stream`].
|
|
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
|
|
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
|
|
//! which do not.
|
|
//!
|
|
//! Network I/O sits behind the [`Transport`] trait so the rest of this
|
|
//! crate, and anything built on it, can be tested against a fake one with
|
|
//! no server involved. [`UreqTransport`] is the only real implementation.
|
|
|
|
use std::io::Read;
|
|
|
|
use event_model::SeqEvent;
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
|
|
/// A request that did not produce what it asked for, carrying the server's
|
|
/// own wording where it sent some.
|
|
///
|
|
/// `status` is the HTTP status where there was a response at all, and
|
|
/// `None` where the server was never reached -- mirroring `ApiException` in
|
|
/// `Api.kt`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ApiError {
|
|
pub message: String,
|
|
pub status: Option<u16>,
|
|
}
|
|
|
|
impl std::fmt::Display for ApiError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&self.message)
|
|
}
|
|
}
|
|
impl std::error::Error for ApiError {}
|
|
|
|
/// A request body to send, in whichever of the two shapes the surface
|
|
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
|
|
pub enum Body {
|
|
Json(Value),
|
|
Bytes {
|
|
content_type: String,
|
|
bytes: Vec<u8>,
|
|
},
|
|
}
|
|
|
|
/// What a transport hands back for a REST call: the status and the body
|
|
/// read whole. A streamed body ([`Transport::stream`]) is a different
|
|
/// method because its whole point is not reading it whole.
|
|
pub struct RawResponse {
|
|
pub status: u16,
|
|
pub body: Vec<u8>,
|
|
}
|
|
|
|
/// The network boundary this crate's pure logic is kept out from behind.
|
|
/// `server/src/routes.rs`'s module doc is the surface this drives.
|
|
pub trait Transport: Send + Sync {
|
|
/// One request/response call -- everything but the long-lived SSE GETs.
|
|
fn request(
|
|
&self,
|
|
method: &str,
|
|
path: &str,
|
|
body: Option<Body>,
|
|
) -> Result<RawResponse, ApiError>;
|
|
|
|
/// Opens `path` and answers a reader over the response body, for a
|
|
/// caller that reads it as a stream rather than all at once (the SSE
|
|
/// connections in [`crate::event_stream`]). Fails the same way
|
|
/// [`Transport::request`] does for a non-2xx response.
|
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
|
|
}
|
|
|
|
/// One session as `GET /sessions` and `GET /sessions/{id}` report it.
|
|
/// Mirrors `Api.kt`'s `SessionSummary`; see that type's doc for what each
|
|
/// field means and why `setup` is never shown.
|
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionSummary {
|
|
pub id: String,
|
|
pub setup: String,
|
|
#[serde(default)]
|
|
pub keeps_own_transcript: bool,
|
|
pub setup_name: String,
|
|
pub provider: String,
|
|
pub title: String,
|
|
#[serde(default)]
|
|
pub model: Option<String>,
|
|
#[serde(default)]
|
|
pub permission_mode: Option<String>,
|
|
#[serde(default)]
|
|
pub imported: bool,
|
|
#[serde(default = "default_true")]
|
|
pub notify: bool,
|
|
#[serde(default)]
|
|
pub cwd: Option<String>,
|
|
#[serde(default)]
|
|
pub context_tokens: Option<u64>,
|
|
#[serde(default)]
|
|
pub max_image_edge: Option<u32>,
|
|
pub status: String,
|
|
pub last_activity: f64,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
/// A client-core equivalent of `requestFromServer` plus the typed calls
|
|
/// built on it. Holds no state of its own beyond the transport -- the
|
|
/// session id or setup id a call is about is a parameter, per this
|
|
/// project's "ask for the least you need".
|
|
pub struct ApiClient<T: Transport> {
|
|
transport: T,
|
|
}
|
|
|
|
impl<T: Transport> ApiClient<T> {
|
|
pub fn new(transport: T) -> Self {
|
|
Self { transport }
|
|
}
|
|
|
|
/// The transport underneath, for a caller that needs the raw SSE
|
|
/// stream (`event_stream::follow_session_events`) rather than one of
|
|
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
|
|
/// is the one that does.
|
|
pub fn transport(&self) -> &T {
|
|
&self.transport
|
|
}
|
|
|
|
fn json_request<R: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
method: &str,
|
|
path: &str,
|
|
body: Option<Value>,
|
|
) -> Result<R, ApiError> {
|
|
let raw = self.transport.request(method, path, body.map(Body::Json))?;
|
|
serde_json::from_slice(&raw.body).map_err(|e| ApiError {
|
|
message: format!("Reached the server but couldn't read its response ({e})"),
|
|
status: Some(raw.status),
|
|
})
|
|
}
|
|
|
|
fn empty_request(&self, method: &str, path: &str, body: Option<Value>) -> Result<(), ApiError> {
|
|
self.transport.request(method, path, body.map(Body::Json))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn fetch_sessions(&self) -> Result<Vec<SessionSummary>, ApiError> {
|
|
self.json_request("GET", "/sessions", None)
|
|
}
|
|
|
|
pub fn fetch_session(&self, session_id: &str) -> Result<SessionSummary, ApiError> {
|
|
self.json_request("GET", &format!("/sessions/{session_id}"), None)
|
|
}
|
|
|
|
pub fn send_message(
|
|
&self,
|
|
session_id: &str,
|
|
text: &str,
|
|
attachment_ids: &[String],
|
|
) -> Result<(), ApiError> {
|
|
self.empty_request(
|
|
"POST",
|
|
&format!("/sessions/{session_id}/message"),
|
|
Some(serde_json::json!({ "text": text, "attachmentIds": attachment_ids })),
|
|
)
|
|
}
|
|
|
|
pub fn unqueue_message(&self, session_id: &str, message_id: &str) -> Result<(), ApiError> {
|
|
self.empty_request(
|
|
"POST",
|
|
&format!("/sessions/{session_id}/unqueue"),
|
|
Some(serde_json::json!({ "messageId": message_id })),
|
|
)
|
|
}
|
|
|
|
pub fn answer_question(
|
|
&self,
|
|
session_id: &str,
|
|
question_id: &str,
|
|
answers: &[String],
|
|
) -> Result<(), ApiError> {
|
|
self.empty_request(
|
|
"POST",
|
|
&format!("/sessions/{session_id}/answer"),
|
|
Some(serde_json::json!({ "questionId": question_id, "answers": answers })),
|
|
)
|
|
}
|
|
|
|
pub fn interrupt_session(&self, session_id: &str) -> Result<(), ApiError> {
|
|
self.empty_request("POST", &format!("/sessions/{session_id}/interrupt"), None)
|
|
}
|
|
|
|
pub fn stop_session(&self, session_id: &str) -> Result<(), ApiError> {
|
|
self.empty_request("POST", &format!("/sessions/{session_id}/stop"), None)
|
|
}
|
|
|
|
pub fn start_session(&self, session_id: &str) -> Result<(), ApiError> {
|
|
self.empty_request("POST", &format!("/sessions/{session_id}/start"), None)
|
|
}
|
|
|
|
pub fn rename_session(&self, session_id: &str, title: &str) -> Result<(), ApiError> {
|
|
self.empty_request(
|
|
"POST",
|
|
&format!("/sessions/{session_id}/title"),
|
|
Some(serde_json::json!({ "title": title })),
|
|
)
|
|
}
|
|
|
|
pub fn set_session_cwd(&self, session_id: &str, cwd: &str) -> Result<(), ApiError> {
|
|
self.empty_request(
|
|
"POST",
|
|
&format!("/sessions/{session_id}/cwd"),
|
|
Some(serde_json::json!({ "cwd": cwd })),
|
|
)
|
|
}
|
|
|
|
pub fn set_session_model(&self, session_id: &str, model: &str) -> Result<(), ApiError> {
|
|
self.empty_request(
|
|
"POST",
|
|
&format!("/sessions/{session_id}/model"),
|
|
Some(serde_json::json!({ "model": model })),
|
|
)
|
|
}
|
|
|
|
pub fn set_session_permission_mode(
|
|
&self,
|
|
session_id: &str,
|
|
mode: &str,
|
|
) -> Result<(), ApiError> {
|
|
self.empty_request(
|
|
"POST",
|
|
&format!("/sessions/{session_id}/permission-mode"),
|
|
Some(serde_json::json!({ "permissionMode": mode })),
|
|
)
|
|
}
|
|
|
|
pub fn set_session_notify(&self, session_id: &str, notify: bool) -> Result<(), ApiError> {
|
|
self.empty_request(
|
|
"POST",
|
|
&format!("/sessions/{session_id}/notify"),
|
|
Some(serde_json::json!({ "notify": notify })),
|
|
)
|
|
}
|
|
|
|
pub fn run_command(&self, session_id: &str, text: &str) -> Result<(), ApiError> {
|
|
self.empty_request(
|
|
"POST",
|
|
&format!("/sessions/{session_id}/command"),
|
|
Some(serde_json::json!({ "text": text })),
|
|
)
|
|
}
|
|
|
|
pub fn compact_session(&self, session_id: &str) -> Result<(), ApiError> {
|
|
self.empty_request("POST", &format!("/sessions/{session_id}/compact"), None)
|
|
}
|
|
|
|
pub fn delete_session(&self, session_id: &str, delete_foreign: bool) -> Result<(), ApiError> {
|
|
let path = if delete_foreign {
|
|
format!("/sessions/{session_id}?deleteForeign=true")
|
|
} else {
|
|
format!("/sessions/{session_id}")
|
|
};
|
|
self.empty_request("DELETE", &path, None)
|
|
}
|
|
|
|
/// A page of transcript history. `before` is the newest-first cursor
|
|
/// (server default is "the newest page" when absent, which a caller
|
|
/// gets by passing `None`); the events themselves are handed back as
|
|
/// [`event_model::SeqEvent`] via `crate::event_stream`'s parsing, kept
|
|
/// out of this method's signature so a caller that only wants the raw
|
|
/// lines (for the transcript cache) is not forced to parse them.
|
|
pub fn fetch_transcript_page(
|
|
&self,
|
|
session_id: &str,
|
|
before: Option<u64>,
|
|
limit: u32,
|
|
coalesce: bool,
|
|
) -> Result<Vec<Value>, ApiError> {
|
|
self.json_request(
|
|
"GET",
|
|
&transcript_path(session_id, before, limit, coalesce, None),
|
|
None,
|
|
)
|
|
}
|
|
|
|
/// A page of transcript history, each line handed back paired with the
|
|
/// exact text it came from, and bounded below by `after` -- the shape
|
|
/// `crate::transcript_source::TranscriptSource` needs to store what it
|
|
/// fetched in the transcript cache without a second round trip to fetch
|
|
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
|
|
///
|
|
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
|
|
/// parsed [`Value`], so the stored line is the exact bytes the server
|
|
/// sent (key order and float literal included) rather than this
|
|
/// crate's own idea of how to write them back out -- the cache and a
|
|
/// live SSE frame must agree byte-for-byte on the same event, which is
|
|
/// exactly what caught the `serde_json` float-rounding bug this
|
|
/// project's `AGENTS.md` records.
|
|
pub fn fetch_transcript_lines(
|
|
&self,
|
|
session_id: &str,
|
|
before: Option<u64>,
|
|
limit: u32,
|
|
coalesce: bool,
|
|
after: Option<u64>,
|
|
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
|
|
let path = transcript_path(session_id, before, limit, coalesce, after);
|
|
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
|
|
raw.into_iter()
|
|
.map(|value| {
|
|
let line = value.get().to_string();
|
|
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
|
|
message: format!(
|
|
"the server sent a transcript line this build couldn't parse: {e}"
|
|
),
|
|
status: None,
|
|
})?;
|
|
Ok((line, event))
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
|
|
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
|
|
/// parameter is written rather than keeping two copies to drift.
|
|
fn transcript_path(
|
|
session_id: &str,
|
|
before: Option<u64>,
|
|
limit: u32,
|
|
coalesce: bool,
|
|
after: Option<u64>,
|
|
) -> String {
|
|
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
|
if let Some(before) = before {
|
|
path.push_str(&format!("&before={before}"));
|
|
}
|
|
if coalesce {
|
|
path.push_str("&coalesce=true");
|
|
}
|
|
if let Some(after) = after {
|
|
path.push_str(&format!("&after={after}"));
|
|
}
|
|
path
|
|
}
|
|
|
|
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
|
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
|
|
/// poll). Verifies the server's leaf against a single pinned CA, the way
|
|
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
|
|
/// store -- the server's certificate is self-signed on purpose (see
|
|
/// `wg-app-link`).
|
|
pub struct UreqTransport {
|
|
agent: ureq::Agent,
|
|
base_url: String,
|
|
token: String,
|
|
}
|
|
|
|
impl UreqTransport {
|
|
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
|
|
/// exactly as read from `certs/ca.pem`.
|
|
pub fn new(
|
|
base_url: impl Into<String>,
|
|
token: impl Into<String>,
|
|
ca_pem: &[u8],
|
|
) -> Result<Self, ApiError> {
|
|
let cert = ureq::tls::Certificate::from_pem(ca_pem).map_err(|e| ApiError {
|
|
message: format!("The pinned CA certificate could not be read: {e}"),
|
|
status: None,
|
|
})?;
|
|
let tls_config = ureq::tls::TlsConfig::builder()
|
|
.root_certs(ureq::tls::RootCerts::new_with_certs(&[cert]))
|
|
.build();
|
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
|
.tls_config(tls_config)
|
|
// Read the body ourselves on every status, the way
|
|
// `requestFromServer` does: the server's own error wording is
|
|
// in the body of a 4xx/5xx, and the default behaviour throws
|
|
// it away before this code can read it.
|
|
.http_status_as_error(false)
|
|
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
|
.build()
|
|
.into();
|
|
Ok(Self {
|
|
agent,
|
|
base_url: base_url.into(),
|
|
token: token.into(),
|
|
})
|
|
}
|
|
|
|
fn url(&self, path: &str) -> String {
|
|
format!("{}{}", self.base_url, path)
|
|
}
|
|
}
|
|
|
|
impl Transport for UreqTransport {
|
|
fn request(
|
|
&self,
|
|
method: &str,
|
|
path: &str,
|
|
body: Option<Body>,
|
|
) -> Result<RawResponse, ApiError> {
|
|
let url = self.url(path);
|
|
let auth = format!("Bearer {}", self.token);
|
|
let mut builder = ureq::http::Request::builder()
|
|
.method(method)
|
|
.uri(&url)
|
|
.header("Authorization", &auth);
|
|
let response = match body {
|
|
None => builder
|
|
.body(())
|
|
.map_err(ureq::Error::from)
|
|
.and_then(|req| self.agent.run(req)),
|
|
Some(Body::Json(value)) => {
|
|
builder = builder.header("Content-Type", "application/json");
|
|
builder
|
|
.body(serde_json::to_vec(&value).unwrap_or_default())
|
|
.map_err(ureq::Error::from)
|
|
.and_then(|req| self.agent.run(req))
|
|
}
|
|
Some(Body::Bytes {
|
|
content_type,
|
|
bytes,
|
|
}) => {
|
|
builder = builder.header("Content-Type", content_type);
|
|
builder
|
|
.body(bytes)
|
|
.map_err(ureq::Error::from)
|
|
.and_then(|req| self.agent.run(req))
|
|
}
|
|
};
|
|
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
|
|
let status = response.status().as_u16();
|
|
let mut body = Vec::new();
|
|
response
|
|
.body_mut()
|
|
.as_reader()
|
|
.read_to_end(&mut body)
|
|
.map_err(|e| ApiError {
|
|
message: format!("Reached {url} but couldn't read its response ({e})"),
|
|
status: Some(status),
|
|
})?;
|
|
if !(200..300).contains(&status) {
|
|
return Err(response_error(status, &body, path));
|
|
}
|
|
Ok(RawResponse { status, body })
|
|
}
|
|
|
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
|
let url = self.url(path);
|
|
let auth = format!("Bearer {}", self.token);
|
|
let response = self
|
|
.agent
|
|
.get(&url)
|
|
.header("Authorization", &auth)
|
|
.header("Accept", "text/event-stream")
|
|
// No read timeout: between events there is nothing to read for
|
|
// as long as the thing being followed is idle, mirroring
|
|
// `EventStream.kt`'s `readTimeout = 0`.
|
|
.config()
|
|
.timeout_recv_response(None)
|
|
.build()
|
|
.call();
|
|
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
|
|
let status = response.status().as_u16();
|
|
if status != 200 {
|
|
let mut body = Vec::new();
|
|
let _ = response.body_mut().as_reader().read_to_end(&mut body);
|
|
return Err(response_error(status, &body, path));
|
|
}
|
|
Ok(Box::new(response.into_body().into_reader()))
|
|
}
|
|
}
|
|
|
|
fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
|
|
ApiError {
|
|
message: format!(
|
|
"Couldn't reach the server at {base_url} ({e}) -- is ai-server running, and is this \
|
|
device able to reach that address (WireGuard up)? [{path}]"
|
|
),
|
|
status: None,
|
|
}
|
|
}
|
|
|
|
/// The 401 wording matches `Api.kt`'s, since that message is instructions
|
|
/// for the reader rather than a diagnostic -- see this project's UI rule
|
|
/// about shortening a failure in one place rather than at each display site.
|
|
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
|
|
let detail = String::from_utf8_lossy(body).trim().to_string();
|
|
let message = if status == 401 {
|
|
"The server rejected this device's token. Re-enroll by scanning the server's QR (or \
|
|
rotate with --rotate-token and scan the new one)."
|
|
.to_string()
|
|
} else if detail.is_empty() {
|
|
format!("Server returned HTTP {status} for {path}")
|
|
} else {
|
|
detail
|
|
};
|
|
ApiError {
|
|
message,
|
|
status: Some(status),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Cursor;
|
|
use std::sync::Mutex;
|
|
|
|
/// A transport with no network at all, for the pure-logic tests this
|
|
/// module can run without a server.
|
|
#[derive(Default)]
|
|
struct FakeTransport {
|
|
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
|
}
|
|
|
|
impl FakeTransport {
|
|
fn respond(&self, method: &str, path: &str, status: u16, body: &str) {
|
|
self.responses.lock().unwrap().push((
|
|
method.to_string(),
|
|
path.to_string(),
|
|
RawResponse {
|
|
status,
|
|
body: body.as_bytes().to_vec(),
|
|
},
|
|
));
|
|
}
|
|
}
|
|
|
|
impl Transport for FakeTransport {
|
|
fn request(
|
|
&self,
|
|
method: &str,
|
|
path: &str,
|
|
_body: Option<Body>,
|
|
) -> Result<RawResponse, ApiError> {
|
|
let mut responses = self.responses.lock().unwrap();
|
|
let index = responses
|
|
.iter()
|
|
.position(|(m, p, _)| m == method && p == path)
|
|
.ok_or_else(|| ApiError {
|
|
message: format!("no fake response for {method} {path}"),
|
|
status: None,
|
|
})?;
|
|
let (_, _, response) = responses.remove(index);
|
|
if !(200..300).contains(&response.status) {
|
|
return Err(response_error(response.status, &response.body, path));
|
|
}
|
|
Ok(response)
|
|
}
|
|
|
|
fn stream(&self, _path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
|
Ok(Box::new(Cursor::new(Vec::new())))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_sessions_parses_the_list() {
|
|
let transport = FakeTransport::default();
|
|
transport.respond(
|
|
"GET",
|
|
"/sessions",
|
|
200,
|
|
r#"[{"id":"s1","setup":"m1","setupName":"desktop","provider":"claude_cli",
|
|
"title":"hi","status":"idle","lastActivity":1.0}]"#,
|
|
);
|
|
let client = ApiClient::new(transport);
|
|
let sessions = client.fetch_sessions().unwrap();
|
|
assert_eq!(sessions.len(), 1);
|
|
assert_eq!(sessions[0].id, "s1");
|
|
assert_eq!(sessions[0].setup_name, "desktop");
|
|
// Defaults for fields the server omits.
|
|
assert!(sessions[0].notify);
|
|
assert_eq!(sessions[0].model, None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_401_gets_the_enrollment_message_regardless_of_the_bare_body() {
|
|
let transport = FakeTransport::default();
|
|
transport.respond("POST", "/sessions/s1/interrupt", 401, "unauthorized");
|
|
let client = ApiClient::new(transport);
|
|
let err = client.interrupt_session("s1").unwrap_err();
|
|
assert!(err.message.contains("Re-enroll"));
|
|
assert_eq!(err.status, Some(401));
|
|
}
|
|
|
|
#[test]
|
|
fn a_bare_error_status_with_no_body_falls_back_to_a_generic_message() {
|
|
let transport = FakeTransport::default();
|
|
transport.respond("POST", "/sessions/s1/stop", 500, "");
|
|
let client = ApiClient::new(transport);
|
|
let err = client.stop_session("s1").unwrap_err();
|
|
assert!(err.message.contains("500"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_server_explanation_in_the_body_is_surfaced_verbatim() {
|
|
let transport = FakeTransport::default();
|
|
transport.respond(
|
|
"POST",
|
|
"/sessions/s1/cwd",
|
|
409,
|
|
"that path does not exist on this machine",
|
|
);
|
|
let client = ApiClient::new(transport);
|
|
let err = client.set_session_cwd("s1", "/nope").unwrap_err();
|
|
assert_eq!(err.message, "that path does not exist on this machine");
|
|
}
|
|
}
|