//! Multi-turn session API (v0.2). //! //! v0.2 adds a parallel `/sessions/*` surface to clawdforge backed by ACPX. A //! [`Session`] is a handle to one server-side session; [`Session::turn`] //! dispatches a single prompt+files turn and returns the structured event //! batch. Sessions are explicitly closed via [`Session::close`] (consumes the //! handle, preventing use-after-close at compile time) or — as a last-resort //! fallback — best-effort closed by [`Drop`] via `tokio::spawn`. //! //! v0.1 single-turn `Client::run` is unchanged; the v0.2 surface is purely //! additive. use std::sync::atomic::{AtomicBool, Ordering}; use serde::{Deserialize, Serialize}; use crate::client::Client; use crate::error::Error; /// Options passed to [`Client::new_session`]. /// /// `Default` produces `agent = None` (server picks `"claude"`) and `meta = /// None`. #[derive(Debug, Default, Clone, Serialize)] pub struct SessionOptions { /// Agent slug to dispatch to. `None` falls back to the server-side default /// (`"claude"`). #[serde(skip_serializing_if = "Option::is_none")] pub agent: Option, /// Free-form metadata stored alongside the session ledger row. #[serde(skip_serializing_if = "Option::is_none")] pub meta: Option, } /// Reply body from `POST /sessions`. #[derive(Debug, Clone, Deserialize)] pub(crate) struct SessionCreateResponse { pub session_id: String, pub agent: String, pub created_at: i64, } /// One event in a turn's structured output. /// /// `event_type` is one of `"thinking"`, `"text"`, `"tool_call"`, etc. (server /// is the authority on the set). #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TurnEvent { /// Event discriminator (`"text"`, `"thinking"`, `"tool_call"`, ...). #[serde(rename = "type")] pub event_type: String, /// Text content for `"text"` and `"thinking"` events. #[serde(default)] pub content: Option, /// Tool name for `"tool_call"` events. #[serde(default)] pub name: Option, /// Tool arguments for `"tool_call"` events. #[serde(default)] pub args: Option, /// Tool result for `"tool_call"` events. #[serde(default)] pub result: Option, } /// Successful response body from `POST /sessions/{id}/turn`. #[derive(Debug, Clone, Deserialize)] pub struct TurnResult { /// Always `true` on a 200 reply. pub ok: bool, /// The session this turn belongs to. pub session_id: String, /// 1-based index of this turn within the session. pub turn_index: i32, /// Structured events emitted during the turn. pub events: Vec, /// Reason the agent stopped (`"end_turn"`, `"max_tokens"`, ...). pub stop_reason: String, /// Wall-clock duration of the turn. pub duration_ms: i64, } impl TurnResult { /// Concatenate all `"text"` event contents into a single string. /// /// Non-text events (`thinking`, `tool_call`, ...) are skipped. If an event /// is `"text"` but `content` is `None`, it contributes the empty string. pub fn text(&self) -> String { let mut out = String::new(); for ev in &self.events { if ev.event_type == "text" { if let Some(c) = ev.content.as_deref() { out.push_str(c); } } } out } } /// Reply body from `GET /sessions/{id}` and entries in `GET /sessions`. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SessionState { /// Server-issued session id. pub session_id: String, /// Agent slug bound to the session. pub agent: String, /// App / consumer name that owns the session. pub app_name: String, /// Unix epoch seconds when created. pub created_at: i64, /// Unix epoch seconds of the last successful turn (or `None` if zero turns /// have been dispatched yet). #[serde(default)] pub last_turn_at: Option, /// Number of turns dispatched. pub turn_count: i32, /// Unix epoch seconds when closed (or `None` if still open). #[serde(default)] pub closed_at: Option, } /// Reply body from `DELETE /sessions/{id}`. #[derive(Debug, Clone, Deserialize)] pub(crate) struct SessionCloseResponse { #[allow(dead_code)] pub ok: bool, #[serde(default)] #[allow(dead_code)] pub already_closed: Option, } /// Reply body from `GET /sessions`. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SessionList { /// All sessions visible to the calling token. pub sessions: Vec, } /// Request body for `POST /sessions/{id}/turn`. #[derive(Debug, Serialize)] struct TurnRequest<'a> { prompt: String, #[serde(skip_serializing_if = "Option::is_none")] files: Option<&'a [String]>, } /// A handle to one server-side multi-turn session. /// /// Construct via [`Client::new_session`]. Drop or [`Session::close`] to /// release the server-side session. `close` consumes the value so use-after- /// close is a compile error; `Drop` is a best-effort backstop that fires an /// async DELETE via `tokio::spawn` and logs (does not panic) on failure. /// /// `Debug` is hand-written and explicitly excludes the embedded [`Client`] so /// no bearer can leak through `{:?}` formatting. pub struct Session { pub(crate) client: Client, pub(crate) session_id: String, pub(crate) agent: String, pub(crate) created_at: i64, pub(crate) closed: AtomicBool, } impl std::fmt::Debug for Session { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Deliberately omit `client` — its Debug already redacts tokens, but // the spec-mandated shape is `Session { session_id, agent, closed }` // to keep the surface minimal and audit-friendly. f.debug_struct("Session") .field("session_id", &self.session_id) .field("agent", &self.agent) .field("created_at", &self.created_at) .field("closed", &self.closed.load(Ordering::Acquire)) .finish() } } impl Session { /// Server-issued session id. pub fn id(&self) -> &str { &self.session_id } /// Agent slug the server bound to this session. pub fn agent(&self) -> &str { &self.agent } /// Unix epoch seconds when the session was created server-side. pub fn created_at(&self) -> i64 { self.created_at } /// Whether the session has been explicitly closed already. Sessions closed /// only via `Drop`'s spawn are still reported as closed once that future /// has run; this getter reflects the in-memory flag. pub fn is_closed(&self) -> bool { self.closed.load(Ordering::Acquire) } /// Send a turn with no attached files. /// /// Equivalent to `turn_with_files(prompt, &[])` but skips serializing the /// `files` field on the wire. pub async fn turn(&mut self, prompt: impl Into) -> Result { self.dispatch_turn(prompt.into(), None).await } /// Send a turn that references previously uploaded file tokens. /// /// `files` is the list of `ff_*` tokens returned by [`Client::upload_file`]. /// /// [`Client::upload_file`]: crate::Client::upload_file pub async fn turn_with_files( &mut self, prompt: impl Into, files: &[String], ) -> Result { self.dispatch_turn(prompt.into(), Some(files)).await } async fn dispatch_turn( &mut self, prompt: String, files: Option<&[String]>, ) -> Result { if self.closed.load(Ordering::Acquire) { return Err(Error::Config("session is closed".into())); } self.client .turn_internal(&self.session_id, &TurnRequest { prompt, files }) .await } /// Explicitly close the session. Consumes `self` — use-after-close is a /// compile error. /// /// If the session is already closed in memory (e.g. via a prior failed /// close or a prior dispatch path that flagged it), this short-circuits /// without contacting the server. pub async fn close(self) -> Result<(), Error> { // Mark closed before the network call so a panic-mid-await on the // request future cannot trigger Drop's spawn into a double-close. if self.closed.swap(true, Ordering::AcqRel) { return Ok(()); } self.client.close_session_internal(&self.session_id).await } } impl Drop for Session { fn drop(&mut self) { // If close() already ran, nothing to do. if self.closed.swap(true, Ordering::AcqRel) { return; } // tokio::spawn panics if no runtime is current. Guard against being // dropped from a sync context (e.g. a forgotten value at the end of a // sync `main`). if tokio::runtime::Handle::try_current().is_err() { tracing::warn!( session_id = %self.session_id, "Session dropped outside a tokio runtime; server-side session not closed" ); return; } let client = self.client.clone(); let id = self.session_id.clone(); tokio::spawn(async move { if let Err(e) = client.close_session_internal(&id).await { tracing::warn!( session_id = %id, error = %e, "best-effort drop close failed" ); } }); } }