From b98f5cb3e5a9c4ad0644ef0607078fd306d9a472 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 5 Sep 2026 01:46:21 +0100 Subject: [PATCH] feat(maestro): serve structured session API --- crates/maestro-acp/src/lib.rs | 44 ++++- crates/maestro-acp/tests/fake_acp_agent.py | 10 ++ crates/maestro-daemon/src/local_api.rs | 176 ++++++++++++++++++- crates/maestro-daemon/src/projection.rs | 4 + crates/maestro-daemon/src/session.rs | 35 ++++ crates/maestro-daemon/src/sidecar.rs | 2 +- crates/maestro-daemon/tests/local_api.rs | 187 ++++++++++++++++++++- crates/maestro-protocol/src/lib.rs | 23 +++ crates/maestro-protocol/src/routes.rs | 60 +++++++ 9 files changed, 534 insertions(+), 7 deletions(-) diff --git a/crates/maestro-acp/src/lib.rs b/crates/maestro-acp/src/lib.rs index 3a01a15..72c3783 100644 --- a/crates/maestro-acp/src/lib.rs +++ b/crates/maestro-acp/src/lib.rs @@ -42,9 +42,10 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use agent_client_protocol::schema::v1::{ - CancelNotification, ClientCapabilities, FileSystemCapabilities, Implementation, - InitializeRequest, LoadSessionRequest, NewSessionRequest, RequestPermissionOutcome, - RequestPermissionRequest, RequestPermissionResponse, SessionId, SessionNotification, + CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, Implementation, + InitializeRequest, LoadSessionRequest, NewSessionRequest, PromptRequest, + RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId, + SessionNotification, }; use agent_client_protocol::schema::ProtocolVersion; use agent_client_protocol::{ByteStreams, Client}; @@ -153,6 +154,10 @@ fn is_executable(path: &Path) -> bool { enum Command { Cancel, + Prompt { + text: String, + done: oneshot::Sender>, + }, Close, } @@ -236,6 +241,18 @@ impl AcpSession { let _ = self.commands.send(Command::Cancel); } + /// Send a user prompt and receive completion once the agent ends the turn. + pub fn prompt(&self, text: String) -> Result>, String> { + let (done_tx, done_rx) = oneshot::channel(); + self.commands + .send(Command::Prompt { + text, + done: done_tx, + }) + .map_err(|_| "ACP connection is closed".to_string())?; + Ok(done_rx) + } + /// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait /// `term_grace`, `SIGKILL`. Returns once the child has been reaped. pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) { @@ -562,6 +579,27 @@ async fn run_connection( SessionId::new(session_id.as_str()), )); } + Some(Command::Prompt { text, done }) => { + let request = PromptRequest::new( + SessionId::new(session_id.as_str()), + vec![ContentBlock::from(text)], + ); + let done = Arc::new(Mutex::new(Some(done))); + let callback_done = done.clone(); + if let Err(error) = cx.send_request(request).on_receiving_result(move |result| { + let callback_done = callback_done.clone(); + async move { + if let Some(done) = callback_done.lock().expect("prompt sender").take() { + let _ = done.send(result.map(|_| ()).map_err(|e| e.to_string())); + } + Ok(()) + } + }) { + if let Some(done) = done.lock().expect("prompt sender").take() { + let _ = done.send(Err(error.to_string())); + } + } + } Some(Command::Close) | None => break, }, _ = cx.incoming_closed() => break, diff --git a/crates/maestro-acp/tests/fake_acp_agent.py b/crates/maestro-acp/tests/fake_acp_agent.py index b1a5a16..b1a379e 100755 --- a/crates/maestro-acp/tests/fake_acp_agent.py +++ b/crates/maestro-acp/tests/fake_acp_agent.py @@ -29,6 +29,8 @@ Environment knobs, all optional: notifications ("replayed ") before answering, the way a real agent streams history back FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error + FAKE_ACP_DELAY_PROMPT_MS=n wait before answering `session/prompt` + FAKE_ACP_PROMPT_FAIL=1 answer `session/prompt` with a JSON-RPC error Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick ", counting from 1) and logs `emit tick `, so a test can make the agent @@ -137,6 +139,14 @@ for raw in sys.stdin: send(chunk("final on cancel")) if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1": sys.exit(0) + elif method == "session/prompt": + time.sleep(int(os.environ.get("FAKE_ACP_DELAY_PROMPT_MS", "0")) / 1000) + if os.environ.get("FAKE_ACP_PROMPT_FAIL") == "1": + send({"jsonrpc": "2.0", "id": rid, + "error": {"code": -32000, "message": "fake prompt failure"}}) + else: + send({"jsonrpc": "2.0", "id": rid, + "result": {"stopReason": "end_turn"}}) elif rid is not None: send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"method not found: {method}"}}) diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index fc5f4e2..8087ace 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -21,12 +21,14 @@ use maestro_config::SpawnPreset; use maestro_protocol::{ routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest, - PROTOCOL_VERSION, + StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION, }; use serde::Deserialize; use uuid::Uuid; +use crate::projection::TurnLifecycle; use crate::session::SessionManager; +use crate::sidecar::EventCursor; #[derive(Debug, Clone)] pub struct LocalApiState { @@ -64,7 +66,11 @@ pub fn router(state: LocalApiState) -> Router { .route(routes::NAMES, get(names)) .route(routes::INFO, get(daemon_info)) .route(routes::EVENTS, any(events_ws)) - .route(routes::patterns::ATTACH, any(attach_ws)); + .route(routes::patterns::ATTACH, any(attach_ws)) + .route(routes::patterns::STRUCTURED, any(structured_ws)) + .route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay)) + .route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt)) + .route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel)); #[cfg(feature = "haiku-autoname")] { router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt)); @@ -369,6 +375,103 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response { (status, Json(ApiError::new(code, message))).into_response() } +#[derive(Debug, Deserialize)] +struct ReplayQuery { + since: Option, + before: Option, + limit: Option, +} + +const MAX_STRUCTURED_REPLAY_LIMIT: usize = 500; + +async fn structured_replay( + State(s): State, + Path(id): Path, + Query(query): Query, +) -> Response { + if query.since.is_some() && query.before.is_some() { + return error_response( + StatusCode::BAD_REQUEST, + "invalid_cursor", + "use either since or before, not both", + ); + } + let cursor = match query.before { + Some(before) => EventCursor::Before(Some(before)), + None => EventCursor::Since(query.since.unwrap_or(0)), + }; + match s.manager.read_acp_events( + id, + cursor, + query + .limit + .unwrap_or(100) + .clamp(1, MAX_STRUCTURED_REPLAY_LIMIT), + ) { + Ok(events) => Json(events).into_response(), + Err(e) => { + let info = e.to_error_info(); + error_response(session_error_status(&info.code), &info.code, &info.message) + } + } +} + +async fn structured_prompt( + State(s): State, + Path(id): Path, + Json(body): Json, +) -> Response { + let acp = match s.manager.begin_acp_turn(id).await { + Ok(Some(acp)) => acp, + Ok(None) => { + return error_response( + StatusCode::CONFLICT, + "turn_in_flight", + "the structured session already has an active turn", + ) + } + Err(e) => { + let info = e.to_error_info(); + return error_response(session_error_status(&info.code), &info.code, &info.message); + } + }; + match acp.prompt(body.prompt) { + Ok(done) => { + let manager = s.manager.clone(); + tokio::spawn(async move { + let lifecycle = match done.await { + Ok(Ok(())) => TurnLifecycle::Finish, + Ok(Err(_)) | Err(_) => TurnLifecycle::Failure, + }; + let _ = manager.apply_acp_turn_lifecycle(id, lifecycle).await; + }); + StatusCode::NO_CONTENT.into_response() + } + Err(message) => { + let _ = s + .manager + .apply_acp_turn_lifecycle(id, TurnLifecycle::Failure) + .await; + error_response(StatusCode::BAD_GATEWAY, "acp_prompt_failed", &message) + } + } +} + +async fn structured_cancel(State(s): State, Path(id): Path) -> Response { + match s.manager.cancel_acp_turn(id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => error_response( + StatusCode::CONFLICT, + "not_structured", + "session is not a live structured session", + ), + Err(e) => { + let info = e.to_error_info(); + error_response(session_error_status(&info.code), &info.code, &info.message) + } + } +} + /// Convenience constructor — pulls the bits `LocalApiState` needs from /// `DaemonConfig` so callers don't have to assemble it by hand. The project /// scanner starts with its cache cold; wire @@ -419,6 +522,75 @@ async fn attach_ws( })) } +#[allow(clippy::result_large_err)] +async fn structured_ws( + Path(id): Path, + State(s): State, + ws: axum::extract::WebSocketUpgrade, +) -> Result { + let Some((snapshot, state, rx)) = s + .manager + .subscribe_acp_projection(id, EventCursor::Since(0)) + .await + .map_err(|e| { + let info = e.to_error_info(); + error_response(session_error_status(&info.code), &info.code, &info.message) + })? + else { + return Err(error_response( + StatusCode::NOT_FOUND, + "unknown_structured_session", + "no live structured session with this id", + )); + }; + Ok(ws.on_upgrade(move |socket| async move { + let _ = pump_structured(socket, snapshot, state, rx).await; + })) +} + +async fn pump_structured( + socket: axum::extract::ws::WebSocket, + snapshot: maestro_protocol::TranscriptSnapshot, + state: maestro_protocol::ReducedState, + mut rx: tokio::sync::broadcast::Receiver, +) -> Result<(), axum::Error> { + use axum::extract::ws::Message; + use futures_util::{SinkExt, StreamExt}; + + let (mut sink, mut stream) = socket.split(); + for event in [ + StructuredEvent::TranscriptSnapshot { snapshot }, + StructuredEvent::ReducedState { state }, + ] { + if sink + .send(Message::Text( + serde_json::to_string(&event).expect("structured event encode"), + )) + .await + .is_err() + { + return Ok(()); + } + } + loop { + tokio::select! { + update = rx.recv() => match update { + Ok(update) => { + let event = StructuredEvent::ProjectionUpdate { transcript: update.transcript, state: update.state }; + if sink.send(Message::Text(serde_json::to_string(&event).expect("structured event encode"))).await.is_err() { break; } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }, + frame = stream.next() => match frame { + None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break, + Some(Ok(_)) => {}, + }, + } + } + Ok(()) +} + // --------------------------------------------------------------------------- // WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248) // --------------------------------------------------------------------------- diff --git a/crates/maestro-daemon/src/projection.rs b/crates/maestro-daemon/src/projection.rs index be7b1cc..426b0fd 100644 --- a/crates/maestro-daemon/src/projection.rs +++ b/crates/maestro-daemon/src/projection.rs @@ -104,6 +104,10 @@ impl Projection { self.state.clone() } + pub fn turn_active(&self) -> bool { + self.turn_active + } + /// Apply a transport-owned prompt lifecycle transition. pub fn turn_lifecycle(&mut self, lifecycle: TurnLifecycle) -> ProjectionUpdate { match lifecycle { diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index bb6f2e3..00f0656 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -417,6 +417,41 @@ impl SessionManager { Ok(true) } + /// Begin an ACP turn only when the previous one has ended. The projection + /// lock makes the rejection and lifecycle update one indivisible action. + pub async fn begin_acp_turn(&self, id: Uuid) -> Result>, SessionError> { + let (acp, stream) = { + let sessions = self.inner.sessions.lock().await; + let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?; + (record.acp.clone(), record.acp_events.clone()) + }; + let (Some(acp), Some(stream)) = (acp, stream) else { + return Ok(None); + }; + let mut stream = stream.lock().await; + if stream.projection.turn_active() { + return Ok(None); + } + let update = stream.projection.turn_lifecycle(TurnLifecycle::Begin); + let _ = stream.projected_live.send(update); + Ok(Some(acp)) + } + + /// Cancel an ACP turn and immediately clear its client-facing lifecycle. + pub async fn cancel_acp_turn(&self, id: Uuid) -> Result { + let acp = { + let sessions = self.inner.sessions.lock().await; + let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?; + record.acp.clone() + }; + let Some(acp) = acp else { + return Ok(false); + }; + acp.cancel(); + self.apply_acp_turn_lifecycle(id, TurnLifecycle::Cancel) + .await + } + /// End every adapter child before the daemon exits (#134): each gets /// `session/cancel`, then SIGTERM, concurrently. The sessions stay in /// the map, marked Dead and written through to their sidecars, so what diff --git a/crates/maestro-daemon/src/sidecar.rs b/crates/maestro-daemon/src/sidecar.rs index f398e37..ab09816 100644 --- a/crates/maestro-daemon/src/sidecar.rs +++ b/crates/maestro-daemon/src/sidecar.rs @@ -247,7 +247,7 @@ pub enum EventCursor { } /// One line of a session's event log. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct LoggedEvent { /// 1-based line position in the log; the cursor for [`EventCursor`]. pub seq: u64, diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index 4ef6071..3355808 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -4,12 +4,14 @@ mod common; use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; +use std::path::Path; use std::time::Duration; use common::DaemonFixture; use futures_util::{SinkExt, StreamExt}; use maestro_daemon::local_api; -use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec}; +use maestro_protocol::{ApiError, CreateSessionRequest, Session, SessionView, WorktreeSpec}; +use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -86,6 +88,189 @@ fn ws_url(addr: SocketAddr, path: &str) -> String { format!("ws://{addr}{path}") } +struct FakeAcp { + dir: TempDir, +} + +impl FakeAcp { + fn new() -> Self { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let adapter = bin.join("claude-code-acp"); + std::fs::copy( + Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py"), + &adapter, + ) + .unwrap(); + std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o755)).unwrap(); + Self { dir } + } + + fn env(&self) -> HashMap { + HashMap::from([( + "PATH".into(), + format!( + "{}:{}", + self.dir.path().join("bin").display(), + std::env::var("PATH").unwrap() + ), + )]) + } +} + +async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid { + h.manager + .create(CreateSessionRequest { + view: SessionView::Structured, + cwd: "/tmp".into(), + command: "claude".into(), + env: fake.env(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .unwrap() + .id +} + +#[tokio::test] +async fn structured_ws_sends_snapshot_then_live_projection() { + let h = spawn("naps-laptop").await; + let fake = FakeAcp::new(); + let id = structured_session(&h, &fake).await; + let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}"))) + .await + .unwrap(); + + let first = ws.next().await.unwrap().unwrap().into_text().unwrap(); + let second = ws.next().await.unwrap().unwrap().into_text().unwrap(); + assert_eq!( + serde_json::from_str::(&first).unwrap()["type"], + "transcript_snapshot" + ); + assert_eq!( + serde_json::from_str::(&second).unwrap()["type"], + "reduced_state" + ); + + let response = reqwest::Client::new() + .post(url( + h.addr, + &format!("/api/sessions/{id}/structured/prompt"), + )) + .json(&serde_json::json!({"prompt": "hello"})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + let live = tokio::time::timeout(Duration::from_secs(1), ws.next()) + .await + .expect("projection update arrives on the subscribed stream") + .unwrap() + .unwrap() + .into_text() + .unwrap(); + assert_eq!( + serde_json::from_str::(&live).unwrap()["type"], + "projection_update" + ); +} + +#[tokio::test] +async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() { + let h = spawn("naps-laptop").await; + let fake = FakeAcp::new(); + let id = structured_session(&h, &fake).await; + let client = reqwest::Client::new(); + let first = client + .post(url( + h.addr, + &format!("/api/sessions/{id}/structured/prompt"), + )) + .json(&serde_json::json!({"prompt": "first"})) + .send() + .await + .unwrap(); + assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT); + let second = client + .post(url( + h.addr, + &format!("/api/sessions/{id}/structured/prompt"), + )) + .json(&serde_json::json!({"prompt": "second"})) + .send() + .await + .unwrap(); + assert_eq!(second.status(), reqwest::StatusCode::CONFLICT); + let error: ApiError = second.json().await.unwrap(); + assert_eq!(error.error.code, "turn_in_flight"); + + let cancel = client + .post(url( + h.addr, + &format!("/api/sessions/{id}/structured/cancel"), + )) + .send() + .await + .unwrap(); + assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT); + let next = client + .post(url( + h.addr, + &format!("/api/sessions/{id}/structured/prompt"), + )) + .json(&serde_json::json!({"prompt": "next"})) + .send() + .await + .unwrap(); + assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn structured_replay_honors_cursors_and_limit() { + let h = spawn("naps-laptop").await; + let fake = FakeAcp::new(); + let id = structured_session(&h, &fake).await; + let client = reqwest::Client::new(); + + let events: Vec = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let events: Vec = client + .get(url( + h.addr, + &format!("/api/sessions/{id}/structured/replay?since=0&limit=1"), + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + if !events.is_empty() { + return events; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["seq"], 1); + + let invalid = client + .get(url( + h.addr, + &format!("/api/sessions/{id}/structured/replay?since=0&before=2"), + )) + .send() + .await + .unwrap(); + assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn info_returns_daemon_identity() { let h = spawn("naps-laptop").await; diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 40bc66e..3f06413 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -836,6 +836,29 @@ pub struct ReducedState { pub mode: Option, } +/// Server messages sent over a structured-session WebSocket. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum StructuredEvent { + TranscriptSnapshot { + snapshot: TranscriptSnapshot, + }, + ReducedState { + state: ReducedState, + }, + ProjectionUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + transcript: Option, + state: ReducedState, + }, +} + +/// HTTP body for `POST /api/sessions/:id/structured/prompt`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StructuredPromptRequest { + pub prompt: String, +} + // --------------------------------------------------------------------------- // Client ↔ Daemon control messages (text frames on the attach WS) // --------------------------------------------------------------------------- diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 1594df8..b402eb4 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -71,6 +71,26 @@ pub fn attach(id: Uuid) -> String { format!("{API_PREFIX}/attach/{id}") } +/// `WS /api/structured/:id` — transcript/state snapshots and live deltas. +pub fn structured(id: Uuid) -> String { + format!("{API_PREFIX}/structured/{id}") +} + +/// `GET /api/sessions/:id/structured/replay` — cursor-bounded ACP history. +pub fn structured_replay(id: Uuid) -> String { + format!("{SESSIONS}/{id}/structured/replay") +} + +/// `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. +pub fn structured_prompt(id: Uuid) -> String { + format!("{SESSIONS}/{id}/structured/prompt") +} + +/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. +pub fn structured_cancel(id: Uuid) -> String { + format!("{SESSIONS}/{id}/structured/cancel") +} + /// Axum-style patterns for the two parameterised routes, so the server /// registers the same paths [`session`] and [`attach`] build. pub mod patterns { @@ -80,6 +100,10 @@ pub mod patterns { pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt"; pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus"; pub const ATTACH: &str = "/api/attach/:id"; + pub const STRUCTURED: &str = "/api/structured/:id"; + pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay"; + pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt"; + pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel"; } /// A daemon base URL that could not be turned into a WebSocket base. @@ -206,6 +230,22 @@ mod tests { attach(id()), "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" ); + assert_eq!( + structured(id()), + "/api/structured/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" + ); + assert_eq!( + structured_replay(id()), + "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/replay" + ); + assert_eq!( + structured_prompt(id()), + "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/prompt" + ); + assert_eq!( + structured_cancel(id()), + "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/cancel" + ); assert_eq!( attach_url("https://laptop.lan:7070", id()).unwrap(), "wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" @@ -240,6 +280,22 @@ mod tests { patterns::ATTACH.trim_end_matches(":id"), attach(id()).trim_end_matches(&id().to_string()) ); + assert_eq!( + patterns::STRUCTURED.trim_end_matches(":id"), + structured(id()).trim_end_matches(&id().to_string()) + ); + assert_eq!( + patterns::STRUCTURED_REPLAY.trim_end_matches(":id/structured/replay"), + session(id()).trim_end_matches(&id().to_string()) + ); + assert_eq!( + patterns::STRUCTURED_PROMPT.trim_end_matches(":id/structured/prompt"), + session(id()).trim_end_matches(&id().to_string()) + ); + assert_eq!( + patterns::STRUCTURED_CANCEL.trim_end_matches(":id/structured/cancel"), + session(id()).trim_end_matches(&id().to_string()) + ); } #[test] @@ -258,5 +314,9 @@ mod tests { assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}"); } assert!(attach(id()).starts_with(API_PREFIX)); + assert!(structured(id()).starts_with(API_PREFIX)); + assert!(structured_replay(id()).starts_with(API_PREFIX)); + assert!(structured_prompt(id()).starts_with(API_PREFIX)); + assert!(structured_cancel(id()).starts_with(API_PREFIX)); } }