feat(maestro): serve structured session API
This commit is contained in:
@@ -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<Result<(), String>>,
|
||||
},
|
||||
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<oneshot::Receiver<Result<(), String>>, 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<T>(
|
||||
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,
|
||||
|
||||
@@ -29,6 +29,8 @@ Environment knobs, all optional:
|
||||
notifications ("replayed <i>") 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 <n>",
|
||||
counting from 1) and logs `emit tick <n>`, 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}"}})
|
||||
|
||||
@@ -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<u64>,
|
||||
before: Option<u64>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
const MAX_STRUCTURED_REPLAY_LIMIT: usize = 500;
|
||||
|
||||
async fn structured_replay(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(query): Query<ReplayQuery>,
|
||||
) -> 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<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<StructuredPromptRequest>,
|
||||
) -> 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<LocalApiState>, Path(id): Path<Uuid>) -> 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<Uuid>,
|
||||
State(s): State<LocalApiState>,
|
||||
ws: axum::extract::WebSocketUpgrade,
|
||||
) -> Result<Response, Response> {
|
||||
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<crate::projection::ProjectionUpdate>,
|
||||
) -> 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Option<Arc<AcpSession>>, 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<bool, SessionError> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, String> {
|
||||
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::<serde_json::Value>(&first).unwrap()["type"],
|
||||
"transcript_snapshot"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&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::<serde_json::Value>(&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<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let events: Vec<serde_json::Value> = 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;
|
||||
|
||||
@@ -836,6 +836,29 @@ pub struct ReducedState {
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<TranscriptDelta>,
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user