Files
ai-app/client-core/src/api.rs
T
irisandClaude Fable 5.1 73251d6b8b client-core: port TranscriptSource and joinPages page-boundary healing
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>
2026-09-06 12:38:59 -04:00

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");
}
}