Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b27dda6c6b | ||
|
|
5bdedc8d23 | ||
|
|
1e628789fb | ||
|
|
3f2473d910 | ||
|
|
b56a35f3ee | ||
|
|
b1f6014a6a | ||
|
|
01db6459cb | ||
|
|
d332c43a9f |
@@ -42,12 +42,13 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use agent_client_protocol::schema::v1::{
|
use agent_client_protocol::schema::v1::{
|
||||||
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
|
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock,
|
||||||
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
|
CreateElicitationRequest, CreateElicitationResponse, CurrentModeUpdate, ElicitationAction,
|
||||||
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
|
ElicitationCapabilities, ElicitationFormCapabilities, FileSystemCapabilities, Implementation,
|
||||||
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
|
InitializeRequest, PromptRequest, RequestPermissionOutcome, RequestPermissionRequest,
|
||||||
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
|
RequestPermissionResponse, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory,
|
||||||
SetSessionConfigOptionRequest, SetSessionModeRequest,
|
SessionConfigOptionValue, SessionConfigSelectOption, SessionId, SessionModeState,
|
||||||
|
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionModeRequest,
|
||||||
};
|
};
|
||||||
use agent_client_protocol::schema::ProtocolVersion;
|
use agent_client_protocol::schema::ProtocolVersion;
|
||||||
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
|
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
|
||||||
@@ -69,13 +70,28 @@ pub enum AcpUpdate {
|
|||||||
/// turn precede this marker on the wire, and therefore in this channel.
|
/// turn precede this marker on the wire, and therefore in this channel.
|
||||||
/// The event pump must fire `done` after persisting every preceding
|
/// The event pump must fire `done` after persisting every preceding
|
||||||
/// notification.
|
/// notification.
|
||||||
|
///
|
||||||
|
/// The `done` oneshot carries both the prompt result and a `settled_tx`
|
||||||
|
/// that the receiver fires after the turn marker is persisted. The
|
||||||
|
/// event pump awaits the corresponding `settled_rx` before processing
|
||||||
|
/// further messages so late-arriving notifications cannot leak into the
|
||||||
|
/// next turn.
|
||||||
TurnDone {
|
TurnDone {
|
||||||
turn: u64,
|
turn: u64,
|
||||||
result: Result<(), String>,
|
result: Result<(), String>,
|
||||||
done: oneshot::Sender<Result<(), String>>,
|
done: oneshot::Sender<TurnDonePayload>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Payload sent through the `done` oneshot inside [`AcpUpdate::TurnDone`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TurnDonePayload {
|
||||||
|
pub result: Result<(), String>,
|
||||||
|
/// The receiver fires this after persisting the turn marker and
|
||||||
|
/// dispatching any queued prompt.
|
||||||
|
pub settled: oneshot::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
/// ACP adapter selected from the structured session's resolved command.
|
/// ACP adapter selected from the structured session's resolved command.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AcpAdapter {
|
pub enum AcpAdapter {
|
||||||
@@ -249,6 +265,15 @@ pub struct PermissionRequest {
|
|||||||
pub response: oneshot::Sender<RequestPermissionOutcome>,
|
pub response: oneshot::Sender<RequestPermissionOutcome>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A blocked ACP elicitation request delivered to the daemon.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ElicitationRequest {
|
||||||
|
/// The prompt turn that was active when ACP delivered this callback.
|
||||||
|
pub turn: u64,
|
||||||
|
pub request: CreateElicitationRequest,
|
||||||
|
pub response: oneshot::Sender<ElicitationAction>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AcpError {
|
pub enum AcpError {
|
||||||
/// The adapter is not on `PATH`. The message names the install command
|
/// The adapter is not on `PATH`. The message names the install command
|
||||||
@@ -301,7 +326,7 @@ enum Command {
|
|||||||
Prompt {
|
Prompt {
|
||||||
text: String,
|
text: String,
|
||||||
turn: u64,
|
turn: u64,
|
||||||
done: oneshot::Sender<Result<(), String>>,
|
done: oneshot::Sender<TurnDonePayload>,
|
||||||
},
|
},
|
||||||
SetConfig {
|
SetConfig {
|
||||||
config_id: String,
|
config_id: String,
|
||||||
@@ -374,6 +399,7 @@ pub struct AcpSession {
|
|||||||
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
||||||
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
|
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
|
||||||
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
||||||
|
elicitation_requests: Mutex<Option<mpsc::UnboundedReceiver<ElicitationRequest>>>,
|
||||||
exit: watch::Receiver<Option<ExitStatus>>,
|
exit: watch::Receiver<Option<ExitStatus>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +450,13 @@ impl AcpSession {
|
|||||||
.take()
|
.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn take_elicitation_requests(&self) -> Option<mpsc::UnboundedReceiver<ElicitationRequest>> {
|
||||||
|
self.elicitation_requests
|
||||||
|
.lock()
|
||||||
|
.expect("elicitation requests")
|
||||||
|
.take()
|
||||||
|
}
|
||||||
|
|
||||||
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
|
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
|
||||||
/// child is already gone, which is the outcome cancel was after.
|
/// child is already gone, which is the outcome cancel was after.
|
||||||
pub fn cancel(&self) {
|
pub fn cancel(&self) {
|
||||||
@@ -435,7 +468,7 @@ impl AcpSession {
|
|||||||
&self,
|
&self,
|
||||||
text: String,
|
text: String,
|
||||||
turn: u64,
|
turn: u64,
|
||||||
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
) -> Result<oneshot::Receiver<TurnDonePayload>, String> {
|
||||||
let (done_tx, done_rx) = oneshot::channel();
|
let (done_tx, done_rx) = oneshot::channel();
|
||||||
self.commands
|
self.commands
|
||||||
.send(Command::Prompt {
|
.send(Command::Prompt {
|
||||||
@@ -558,6 +591,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
|
|
||||||
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
||||||
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
|
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
|
||||||
|
let (elicitation_tx, elicitation_rx) = mpsc::unbounded_channel();
|
||||||
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
|
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
|
||||||
let (ready_tx, ready_rx) = oneshot::channel();
|
let (ready_tx, ready_rx) = oneshot::channel();
|
||||||
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
||||||
@@ -570,6 +604,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
},
|
},
|
||||||
updates_tx,
|
updates_tx,
|
||||||
permission_tx,
|
permission_tx,
|
||||||
|
elicitation_tx,
|
||||||
commands_rx,
|
commands_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
));
|
));
|
||||||
@@ -584,6 +619,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
child_commands: child_commands_tx,
|
child_commands: child_commands_tx,
|
||||||
updates: Mutex::new(Some(updates_rx)),
|
updates: Mutex::new(Some(updates_rx)),
|
||||||
permission_requests: Mutex::new(Some(permission_rx)),
|
permission_requests: Mutex::new(Some(permission_rx)),
|
||||||
|
elicitation_requests: Mutex::new(Some(elicitation_rx)),
|
||||||
exit: exit_rx,
|
exit: exit_rx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -690,6 +726,7 @@ fn client_capabilities() -> ClientCapabilities {
|
|||||||
.read_text_file(false)
|
.read_text_file(false)
|
||||||
.write_text_file(false))
|
.write_text_file(false))
|
||||||
.terminal(false)
|
.terminal(false)
|
||||||
|
.elicitation(ElicitationCapabilities::new().form(ElicitationFormCapabilities::default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn advertised_config_options(
|
fn advertised_config_options(
|
||||||
@@ -792,6 +829,7 @@ async fn run_connection<T>(
|
|||||||
config: ConnectionConfig,
|
config: ConnectionConfig,
|
||||||
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
|
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
|
||||||
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||||
|
elicitation_requests: mpsc::UnboundedSender<ElicitationRequest>,
|
||||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||||
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
||||||
) where
|
) where
|
||||||
@@ -807,6 +845,7 @@ async fn run_connection<T>(
|
|||||||
// until a cancelled prompt settles, so this active turn is unambiguous.
|
// until a cancelled prompt settles, so this active turn is unambiguous.
|
||||||
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
|
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
|
||||||
let permission_turn_seen = permission_turn.clone();
|
let permission_turn_seen = permission_turn.clone();
|
||||||
|
let elicitation_turn_seen = permission_turn.clone();
|
||||||
let received_updates = updates_tx.clone();
|
let received_updates = updates_tx.clone();
|
||||||
let result = Client
|
let result = Client
|
||||||
.builder()
|
.builder()
|
||||||
@@ -852,6 +891,35 @@ async fn run_connection<T>(
|
|||||||
},
|
},
|
||||||
agent_client_protocol::on_receive_request!(),
|
agent_client_protocol::on_receive_request!(),
|
||||||
)
|
)
|
||||||
|
.on_receive_request(
|
||||||
|
async move |request: CreateElicitationRequest, responder, _cx| {
|
||||||
|
let turn = elicitation_turn_seen
|
||||||
|
.lock()
|
||||||
|
.expect("elicitation turn state")
|
||||||
|
.callback_turn();
|
||||||
|
let Some(turn) = turn else {
|
||||||
|
return responder.respond(CreateElicitationResponse::new(
|
||||||
|
ElicitationAction::Cancel,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let (response, received) = oneshot::channel();
|
||||||
|
if elicitation_requests
|
||||||
|
.send(ElicitationRequest {
|
||||||
|
turn,
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return responder.respond(CreateElicitationResponse::new(
|
||||||
|
ElicitationAction::Cancel,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let action = received.await.unwrap_or(ElicitationAction::Cancel);
|
||||||
|
responder.respond(CreateElicitationResponse::new(action))
|
||||||
|
},
|
||||||
|
agent_client_protocol::on_receive_request!(),
|
||||||
|
)
|
||||||
.connect_with(transport, async move |cx| {
|
.connect_with(transport, async move |cx| {
|
||||||
let init = InitializeRequest::new(ProtocolVersion::V1)
|
let init = InitializeRequest::new(ProtocolVersion::V1)
|
||||||
.client_capabilities(client_capabilities())
|
.client_capabilities(client_capabilities())
|
||||||
@@ -999,7 +1067,11 @@ async fn run_connection<T>(
|
|||||||
.expect("permission turn state")
|
.expect("permission turn state")
|
||||||
.finish(turn);
|
.finish(turn);
|
||||||
if let Some(done) = done.lock().expect("prompt sender").take() {
|
if let Some(done) = done.lock().expect("prompt sender").take() {
|
||||||
let _ = done.send(Err(error.to_string()));
|
let (settled_tx, _) = oneshot::channel();
|
||||||
|
let _ = done.send(TurnDonePayload {
|
||||||
|
result: Err(error.to_string()),
|
||||||
|
settled: settled_tx,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,11 +70,12 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
|||||||
pub fn decide_structured(
|
pub fn decide_structured(
|
||||||
turn_active: bool,
|
turn_active: bool,
|
||||||
pending_approval: bool,
|
pending_approval: bool,
|
||||||
|
pending_elicitation: bool,
|
||||||
turn_failed: bool,
|
turn_failed: bool,
|
||||||
) -> ActivityState {
|
) -> ActivityState {
|
||||||
if turn_failed {
|
if turn_failed {
|
||||||
ActivityState::Error
|
ActivityState::Error
|
||||||
} else if pending_approval {
|
} else if pending_approval || pending_elicitation {
|
||||||
ActivityState::AwaitingInput
|
ActivityState::AwaitingInput
|
||||||
} else if turn_active {
|
} else if turn_active {
|
||||||
ActivityState::Active
|
ActivityState::Active
|
||||||
@@ -155,13 +156,26 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
||||||
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
|
|
||||||
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decide_structured(true, true, false),
|
decide_structured(false, false, false, false),
|
||||||
|
ActivityState::Idle
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide_structured(true, false, false, false),
|
||||||
|
ActivityState::Active
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide_structured(true, true, false, false),
|
||||||
ActivityState::AwaitingInput
|
ActivityState::AwaitingInput
|
||||||
);
|
);
|
||||||
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
|
assert_eq!(
|
||||||
|
decide_structured(true, false, true, false),
|
||||||
|
ActivityState::AwaitingInput
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide_structured(false, false, false, true),
|
||||||
|
ActivityState::Error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ use maestro_config::SpawnPreset;
|
|||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||||
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
|
StructuredApprovalDecision, StructuredConfigRequest, StructuredElicitationAction,
|
||||||
PROTOCOL_VERSION,
|
StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -80,6 +80,10 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(
|
.route(
|
||||||
routes::patterns::STRUCTURED_APPROVAL,
|
routes::patterns::STRUCTURED_APPROVAL,
|
||||||
post(structured_approval),
|
post(structured_approval),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_ELICITATION,
|
||||||
|
post(structured_elicitation),
|
||||||
);
|
);
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
{
|
{
|
||||||
@@ -577,6 +581,53 @@ async fn structured_approval(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn structured_elicitation(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path((id, nonce)): Path<(Uuid, String)>,
|
||||||
|
Json(body): Json<StructuredElicitationAction>,
|
||||||
|
) -> Response {
|
||||||
|
use maestro_acp::schema::{
|
||||||
|
ElicitationAcceptAction, ElicitationAction, ElicitationContentValue,
|
||||||
|
};
|
||||||
|
let action = match body {
|
||||||
|
StructuredElicitationAction::Accept { content } => {
|
||||||
|
let mapped: std::collections::BTreeMap<String, ElicitationContentValue> = content
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(key, value)| {
|
||||||
|
let v = match value {
|
||||||
|
serde_json::Value::String(s) => ElicitationContentValue::String(s),
|
||||||
|
serde_json::Value::Bool(b) => ElicitationContentValue::Boolean(b),
|
||||||
|
serde_json::Value::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
ElicitationContentValue::Integer(i)
|
||||||
|
} else {
|
||||||
|
ElicitationContentValue::Number(n.as_f64()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((key, v))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
ElicitationAction::Accept(ElicitationAcceptAction::new().content(mapped))
|
||||||
|
}
|
||||||
|
StructuredElicitationAction::Decline => ElicitationAction::Decline,
|
||||||
|
StructuredElicitationAction::Cancel => ElicitationAction::Cancel,
|
||||||
|
};
|
||||||
|
match s.manager.resolve_acp_elicitation(id, &nonce, action).await {
|
||||||
|
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(None) => 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
|
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||||
/// scanner starts with its cache cold; wire
|
/// scanner starts with its cache cold; wire
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ impl Projection {
|
|||||||
crate::activity::decide_structured(
|
crate::activity::decide_structured(
|
||||||
self.active_turn.is_some(),
|
self.active_turn.is_some(),
|
||||||
self.state.pending_approval.is_some(),
|
self.state.pending_approval.is_some(),
|
||||||
|
self.state.pending_elicitation.is_some(),
|
||||||
self.turn_failed,
|
self.turn_failed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -274,6 +275,45 @@ impl Projection {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a daemon-originated elicitation row. The nonce belongs only in
|
||||||
|
/// the reduced state, not in the row data.
|
||||||
|
pub fn request_elicitation(&mut self, nonce: String, data: Value) -> ProjectionUpdate {
|
||||||
|
let row = self.append(
|
||||||
|
format!("elicitation:{}", uuid::Uuid::new_v4()),
|
||||||
|
TranscriptRowKind::Elicitation,
|
||||||
|
data.clone(),
|
||||||
|
);
|
||||||
|
self.state.pending_elicitation = Some(serde_json::json!({
|
||||||
|
"nonce": nonce,
|
||||||
|
"request": data,
|
||||||
|
}));
|
||||||
|
ProjectionUpdate {
|
||||||
|
transcript: Some(row),
|
||||||
|
state: self.state(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear an elicitation only when it is still the one the client answered.
|
||||||
|
pub fn resolve_elicitation(&mut self, nonce: &str) -> Option<ProjectionUpdate> {
|
||||||
|
let pending = self.state.pending_elicitation.as_ref()?;
|
||||||
|
if pending.get("nonce").and_then(Value::as_str) != Some(nonce) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.state.pending_elicitation = None;
|
||||||
|
Some(ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: self.state(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_elicitation(&mut self) -> Option<ProjectionUpdate> {
|
||||||
|
self.state.pending_elicitation.take()?;
|
||||||
|
Some(ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: self.state(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Undo an `append`ed row that a caller decides never happened — used
|
/// Undo an `append`ed row that a caller decides never happened — used
|
||||||
/// when persisting the row's own event fails (#184), so a snapshot taken
|
/// when persisting the row's own event fails (#184), so a snapshot taken
|
||||||
/// after the failure cannot show a row durable replay would never
|
/// after the failure cannot show a row durable replay would never
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ struct AcpEventStream {
|
|||||||
projection: Projection,
|
projection: Projection,
|
||||||
projected_live: broadcast::Sender<ProjectionUpdate>,
|
projected_live: broadcast::Sender<ProjectionUpdate>,
|
||||||
pending_approvals: HashMap<String, PendingApproval>,
|
pending_approvals: HashMap<String, PendingApproval>,
|
||||||
|
pending_elicitations: HashMap<String, PendingElicitation>,
|
||||||
/// Serializes prompt dispatch with cancellation. A prompt is not merely
|
/// Serializes prompt dispatch with cancellation. A prompt is not merely
|
||||||
/// claimed: it is queued onto ACP before another operation can proceed.
|
/// claimed: it is queued onto ACP before another operation can proceed.
|
||||||
operation: Arc<Mutex<()>>,
|
operation: Arc<Mutex<()>>,
|
||||||
@@ -205,6 +206,11 @@ struct PendingApproval {
|
|||||||
options: Vec<maestro_acp::schema::PermissionOption>,
|
options: Vec<maestro_acp::schema::PermissionOption>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PendingElicitation {
|
||||||
|
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The outcome accepted by the structured approval endpoint.
|
/// The outcome accepted by the structured approval endpoint.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ApprovalDecision {
|
pub enum ApprovalDecision {
|
||||||
@@ -712,6 +718,7 @@ impl SessionManager {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
|
cancel_pending_elicitations(&mut stream);
|
||||||
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
|
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
@@ -869,11 +876,24 @@ impl SessionManager {
|
|||||||
stream.dispatch.queued_turn = Some(turn);
|
stream.dispatch.queued_turn = Some(turn);
|
||||||
let manager = self.clone();
|
let manager = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let lifecycle = match done.await {
|
let (lifecycle, settled) = match done.await {
|
||||||
Ok(Ok(())) => TurnLifecycle::Finish,
|
Ok(payload) => {
|
||||||
Ok(Err(_)) | Err(_) => TurnLifecycle::Failure,
|
let lifecycle = if payload.result.is_ok() {
|
||||||
|
TurnLifecycle::Finish
|
||||||
|
} else {
|
||||||
|
TurnLifecycle::Failure
|
||||||
|
};
|
||||||
|
(lifecycle, Some(payload.settled))
|
||||||
|
}
|
||||||
|
Err(_) => (TurnLifecycle::Failure, None),
|
||||||
};
|
};
|
||||||
manager.finish_queued_acp_turn(id, turn, lifecycle).await;
|
manager.finish_queued_acp_turn(id, turn, lifecycle).await;
|
||||||
|
// Tell the event pump it may resume processing
|
||||||
|
// notifications — the turn marker is persisted
|
||||||
|
// and any queued prompt has been dispatched.
|
||||||
|
if let Some(settled) = settled {
|
||||||
|
let _ = settled.send(());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
drop(stream);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
sync_structured_activity(&self.inner, id).await;
|
||||||
@@ -885,6 +905,7 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
|
cancel_pending_elicitations(&mut stream);
|
||||||
let projected = stream
|
let projected = stream
|
||||||
.projection
|
.projection
|
||||||
.turn_lifecycle(TurnLifecycle::Failure, turn)
|
.turn_lifecycle(TurnLifecycle::Failure, turn)
|
||||||
@@ -955,6 +976,7 @@ impl SessionManager {
|
|||||||
stream.dispatch.settled.notify_waiters();
|
stream.dispatch.settled.notify_waiters();
|
||||||
if stream.projection.active_turn() == Some(turn) {
|
if stream.projection.active_turn() == Some(turn) {
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
|
cancel_pending_elicitations(&mut stream);
|
||||||
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
|
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
|
||||||
let projected = persist_turn_update(&mut stream, projected);
|
let projected = persist_turn_update(&mut stream, projected);
|
||||||
stream.broadcast(projected);
|
stream.broadcast(projected);
|
||||||
@@ -1077,6 +1099,7 @@ impl SessionManager {
|
|||||||
let mut stream = event_stream.lock().await;
|
let mut stream = event_stream.lock().await;
|
||||||
acp.cancel();
|
acp.cancel();
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
|
cancel_pending_elicitations(&mut stream);
|
||||||
let cancelled_queued_turn = stream.dispatch.queued_turn;
|
let cancelled_queued_turn = stream.dispatch.queued_turn;
|
||||||
if let Some(turn) = cancelled_queued_turn {
|
if let Some(turn) = cancelled_queued_turn {
|
||||||
stream.dispatch.cancelled_turn = Some(turn);
|
stream.dispatch.cancelled_turn = Some(turn);
|
||||||
@@ -1149,6 +1172,38 @@ impl SessionManager {
|
|||||||
Ok(Some(()))
|
Ok(Some(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a daemon-issued elicitation nonce. Same idempotency rules as
|
||||||
|
/// `resolve_acp_approval`: stale/duplicate nonces succeed silently.
|
||||||
|
pub async fn resolve_acp_elicitation(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
nonce: &str,
|
||||||
|
action: maestro_acp::schema::ElicitationAction,
|
||||||
|
) -> Result<Option<()>, SessionError> {
|
||||||
|
let stream = {
|
||||||
|
let sessions = self.inner.sessions.lock().await;
|
||||||
|
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
if !record.is_live_structured() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
record.acp_events.clone()
|
||||||
|
};
|
||||||
|
let Some(stream) = stream else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let mut stream = stream.lock().await;
|
||||||
|
let Some(pending) = stream.pending_elicitations.remove(nonce) else {
|
||||||
|
return Ok(Some(()));
|
||||||
|
};
|
||||||
|
let _ = pending.response.send(action);
|
||||||
|
if let Some(update) = stream.projection.resolve_elicitation(nonce) {
|
||||||
|
stream.broadcast(update);
|
||||||
|
}
|
||||||
|
drop(stream);
|
||||||
|
sync_structured_activity(&self.inner, id).await;
|
||||||
|
Ok(Some(()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn cancel_acp_approvals(&self, id: Uuid) {
|
async fn cancel_acp_approvals(&self, id: Uuid) {
|
||||||
let stream = self
|
let stream = self
|
||||||
.inner
|
.inner
|
||||||
@@ -1158,7 +1213,9 @@ impl SessionManager {
|
|||||||
.get(&id)
|
.get(&id)
|
||||||
.and_then(|record| record.acp_events.clone());
|
.and_then(|record| record.acp_events.clone());
|
||||||
if let Some(stream) = stream {
|
if let Some(stream) = stream {
|
||||||
cancel_pending_approvals(&mut *stream.lock().await);
|
let mut events = stream.lock().await;
|
||||||
|
cancel_pending_approvals(&mut events);
|
||||||
|
cancel_pending_elicitations(&mut events);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1448,6 +1505,7 @@ impl SessionManager {
|
|||||||
projection: Projection::default(),
|
projection: Projection::default(),
|
||||||
projected_live: projected_tx,
|
projected_live: projected_tx,
|
||||||
pending_approvals: HashMap::new(),
|
pending_approvals: HashMap::new(),
|
||||||
|
pending_elicitations: HashMap::new(),
|
||||||
operation: Arc::new(Mutex::new(())),
|
operation: Arc::new(Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
prompt_queue: VecDeque::new(),
|
prompt_queue: VecDeque::new(),
|
||||||
@@ -1601,6 +1659,7 @@ impl SessionManager {
|
|||||||
let acp = Arc::new(acp_session);
|
let acp = Arc::new(acp_session);
|
||||||
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
|
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
|
||||||
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
|
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
|
||||||
|
start_acp_elicitation_pump(&inner, id, &acp, events.clone());
|
||||||
// Drain any prompts queued while the adapter was Starting.
|
// Drain any prompts queued while the adapter was Starting.
|
||||||
let queued_text = {
|
let queued_text = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
@@ -1968,6 +2027,7 @@ impl SessionManager {
|
|||||||
projection,
|
projection,
|
||||||
projected_live: projected_tx,
|
projected_live: projected_tx,
|
||||||
pending_approvals: HashMap::new(),
|
pending_approvals: HashMap::new(),
|
||||||
|
pending_elicitations: HashMap::new(),
|
||||||
operation: Arc::new(Mutex::new(())),
|
operation: Arc::new(Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
prompt_queue: session.queued_prompts.drain(..).collect(),
|
prompt_queue: session.queued_prompts.drain(..).collect(),
|
||||||
@@ -2026,7 +2086,14 @@ impl SessionManager {
|
|||||||
);
|
);
|
||||||
if let Some((acp, events)) = event_pump {
|
if let Some((acp, events)) = event_pump {
|
||||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||||
start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
|
start_acp_permission_pump(
|
||||||
|
&self.inner,
|
||||||
|
id,
|
||||||
|
&acp,
|
||||||
|
events.clone(),
|
||||||
|
session.auto_approve,
|
||||||
|
);
|
||||||
|
start_acp_elicitation_pump(&self.inner, id, &acp, events);
|
||||||
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
||||||
record.acp_event_pump = Some(done);
|
record.acp_event_pump = Some(done);
|
||||||
}
|
}
|
||||||
@@ -2443,8 +2510,17 @@ fn start_acp_event_pump(
|
|||||||
} => {
|
} => {
|
||||||
// Every notification for this turn has been persisted and
|
// Every notification for this turn has been persisted and
|
||||||
// broadcast above. Signal the done oneshot so
|
// broadcast above. Signal the done oneshot so
|
||||||
// finish_queued_acp_turn runs with the transcript complete.
|
// finish_queued_acp_turn runs with the transcript complete,
|
||||||
let _ = done.send(result);
|
// then wait for it to persist the turn marker before we
|
||||||
|
// resume — otherwise late-arriving notifications race into
|
||||||
|
// the next turn.
|
||||||
|
let (settled_tx, settled_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let _ = done.send(maestro_acp::TurnDonePayload {
|
||||||
|
result,
|
||||||
|
settled: settled_tx,
|
||||||
|
});
|
||||||
|
// Block the pump until the turn is fully settled.
|
||||||
|
let _ = settled_rx.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2458,6 +2534,7 @@ fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> Pr
|
|||||||
PersistedEvent::Notification(notification) => projection.apply(event.seq, notification),
|
PersistedEvent::Notification(notification) => projection.apply(event.seq, notification),
|
||||||
PersistedEvent::User { row }
|
PersistedEvent::User { row }
|
||||||
| PersistedEvent::Approval { row }
|
| PersistedEvent::Approval { row }
|
||||||
|
| PersistedEvent::Elicitation { row }
|
||||||
| PersistedEvent::Turn { row } => projection.restore_row(row.clone()),
|
| PersistedEvent::Turn { row } => projection.restore_row(row.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2735,6 +2812,110 @@ fn permission_outcome(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Elicitation pump
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn start_acp_elicitation_pump(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
id: Uuid,
|
||||||
|
acp: &Arc<AcpSession>,
|
||||||
|
events: Arc<Mutex<AcpEventStream>>,
|
||||||
|
) {
|
||||||
|
let Some(mut requests) = acp.take_elicitation_requests() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let weak_inner = Arc::downgrade(inner);
|
||||||
|
let weak_acp = Arc::downgrade(acp);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(maestro_acp::ElicitationRequest {
|
||||||
|
turn,
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
}) = requests.recv().await
|
||||||
|
{
|
||||||
|
let mut events = events.lock().await;
|
||||||
|
if events.projection.active_turn() != Some(turn) {
|
||||||
|
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let outcome = record_elicitation_request(&mut events, request, response);
|
||||||
|
let fatal = matches!(outcome, RecordOutcome::Fatal);
|
||||||
|
if let RecordOutcome::Live(update) = outcome {
|
||||||
|
events.broadcast(*update);
|
||||||
|
}
|
||||||
|
drop(events);
|
||||||
|
if fatal {
|
||||||
|
tracing::error!(%id, "elicitation history append rollback did not restore consistency");
|
||||||
|
if let Some(acp) = weak_acp.upgrade() {
|
||||||
|
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if let Some(inner) = weak_inner.upgrade() {
|
||||||
|
mark_session_dead(&inner, id).await;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(inner) = weak_inner.upgrade() {
|
||||||
|
sync_structured_activity(&inner, id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_elicitation_request(
|
||||||
|
events: &mut AcpEventStream,
|
||||||
|
request: maestro_acp::schema::CreateElicitationRequest,
|
||||||
|
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
|
||||||
|
) -> RecordOutcome {
|
||||||
|
let nonce = Uuid::new_v4().to_string();
|
||||||
|
let data = match serde_json::to_value(&request) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
|
||||||
|
return RecordOutcome::Dropped;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cancel_pending_elicitations(events);
|
||||||
|
events
|
||||||
|
.pending_elicitations
|
||||||
|
.insert(nonce.clone(), PendingElicitation { response });
|
||||||
|
let update = events.projection.request_elicitation(nonce, data);
|
||||||
|
let Some(TranscriptDelta::Append { row }) = &update.transcript else {
|
||||||
|
return RecordOutcome::Live(Box::new(update));
|
||||||
|
};
|
||||||
|
let event = PersistedEvent::Elicitation { row: row.clone() };
|
||||||
|
match events.log.append(&event) {
|
||||||
|
Ok(seq) => {
|
||||||
|
let _ = events.live.send(LoggedEvent { seq, event });
|
||||||
|
RecordOutcome::Live(Box::new(update))
|
||||||
|
}
|
||||||
|
Err(AppendError::Failed(error)) => {
|
||||||
|
tracing::error!(%error, "elicitation history append failed");
|
||||||
|
events.projection.rollback_row(&row.id);
|
||||||
|
cancel_pending_elicitations(events);
|
||||||
|
RecordOutcome::Dropped
|
||||||
|
}
|
||||||
|
Err(fatal @ AppendError::Fatal { .. }) => {
|
||||||
|
tracing::error!(error = %fatal, "elicitation history append failed unrecoverably");
|
||||||
|
events.projection.rollback_row(&row.id);
|
||||||
|
cancel_pending_elicitations(events);
|
||||||
|
RecordOutcome::Fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_pending_elicitations(events: &mut AcpEventStream) {
|
||||||
|
for (_, pending) in events.pending_elicitations.drain() {
|
||||||
|
let _ = pending
|
||||||
|
.response
|
||||||
|
.send(maestro_acp::schema::ElicitationAction::Cancel);
|
||||||
|
}
|
||||||
|
if let Some(update) = events.projection.clear_elicitation() {
|
||||||
|
events.broadcast(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_event_pump(mut done: watch::Receiver<bool>) {
|
async fn wait_for_event_pump(mut done: watch::Receiver<bool>) {
|
||||||
if !*done.borrow() {
|
if !*done.borrow() {
|
||||||
let _ = done.wait_for(|finished| *finished).await;
|
let _ = done.wait_for(|finished| *finished).await;
|
||||||
@@ -3110,6 +3291,7 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
if let Some(stream) = stream {
|
if let Some(stream) = stream {
|
||||||
let mut stream = stream.lock().await;
|
let mut stream = stream.lock().await;
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
|
cancel_pending_elicitations(&mut stream);
|
||||||
stream.prompt_queue.clear();
|
stream.prompt_queue.clear();
|
||||||
}
|
}
|
||||||
tracing::info!(%id, "session process gone — marking dead");
|
tracing::info!(%id, "session process gone — marking dead");
|
||||||
@@ -3211,7 +3393,9 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
|
|||||||
}
|
}
|
||||||
if let Some(acp) = &record.acp {
|
if let Some(acp) = &record.acp {
|
||||||
if let Some(stream) = &record.acp_events {
|
if let Some(stream) = &record.acp_events {
|
||||||
cancel_pending_approvals(&mut *stream.lock().await);
|
let mut events = stream.lock().await;
|
||||||
|
cancel_pending_approvals(&mut events);
|
||||||
|
cancel_pending_elicitations(&mut events);
|
||||||
}
|
}
|
||||||
// `session/cancel`, a short wait, then SIGTERM (#134).
|
// `session/cancel`, a short wait, then SIGTERM (#134).
|
||||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
@@ -3299,6 +3483,7 @@ mod tests {
|
|||||||
projection: Projection::default(),
|
projection: Projection::default(),
|
||||||
projected_live,
|
projected_live,
|
||||||
pending_approvals: std::collections::HashMap::new(),
|
pending_approvals: std::collections::HashMap::new(),
|
||||||
|
pending_elicitations: std::collections::HashMap::new(),
|
||||||
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
prompt_queue: std::collections::VecDeque::new(),
|
prompt_queue: std::collections::VecDeque::new(),
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ pub enum PersistedEvent {
|
|||||||
Approval {
|
Approval {
|
||||||
row: TranscriptRow,
|
row: TranscriptRow,
|
||||||
},
|
},
|
||||||
|
Elicitation {
|
||||||
|
row: TranscriptRow,
|
||||||
|
},
|
||||||
Turn {
|
Turn {
|
||||||
row: TranscriptRow,
|
row: TranscriptRow,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("[turn]"),
|
.unwrap_or("[turn]"),
|
||||||
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
|
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
|
||||||
|
maestro_daemon::sidecar::PersistedEvent::Elicitation { .. } => "[elicitation]",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -806,6 +806,7 @@ pub enum TranscriptRowKind {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
ToolDiff,
|
ToolDiff,
|
||||||
Approval,
|
Approval,
|
||||||
|
Elicitation,
|
||||||
Plan,
|
Plan,
|
||||||
Notice,
|
Notice,
|
||||||
}
|
}
|
||||||
@@ -871,6 +872,8 @@ pub struct ReducedState {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub pending_approval: Option<Value>,
|
pub pending_approval: Option<Value>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pending_elicitation: Option<Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub plan: Option<Value>,
|
pub plan: Option<Value>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub usage: Option<Value>,
|
pub usage: Option<Value>,
|
||||||
@@ -927,6 +930,21 @@ pub enum StructuredApprovalDecision {
|
|||||||
Deny,
|
Deny,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Operator response to a pending ACP elicitation request.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "action", rename_all = "snake_case")]
|
||||||
|
pub enum StructuredElicitationAction {
|
||||||
|
/// The operator filled in the form and submitted.
|
||||||
|
Accept {
|
||||||
|
#[serde(default)]
|
||||||
|
content: std::collections::BTreeMap<String, Value>,
|
||||||
|
},
|
||||||
|
/// The operator declined to answer.
|
||||||
|
Decline,
|
||||||
|
/// The elicitation was cancelled.
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Client ↔ Daemon control messages (text frames on the attach WS)
|
// Client ↔ Daemon control messages (text frames on the attach WS)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ pub fn structured_approval(id: Uuid, nonce: &str) -> String {
|
|||||||
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
|
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/structured/elicitations/:nonce` — answer ACP elicitation.
|
||||||
|
pub fn structured_elicitation(id: Uuid, nonce: &str) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/elicitations/{nonce}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Axum-style patterns for the two parameterised routes, so the server
|
/// Axum-style patterns for the two parameterised routes, so the server
|
||||||
/// registers the same paths [`session`] and [`attach`] build.
|
/// registers the same paths [`session`] and [`attach`] build.
|
||||||
pub mod patterns {
|
pub mod patterns {
|
||||||
@@ -122,6 +127,7 @@ pub mod patterns {
|
|||||||
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
||||||
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
||||||
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||||
|
pub const STRUCTURED_ELICITATION: &str = "/api/sessions/:id/structured/elicitations/:nonce";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon base URL that could not be turned into a WebSocket base.
|
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||||
|
|||||||
@@ -355,6 +355,15 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
|||||||
Tone::Muted,
|
Tone::Muted,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
TranscriptRowKind::Elicitation => push_wrapped(
|
||||||
|
&mut out,
|
||||||
|
row,
|
||||||
|
"? ",
|
||||||
|
content_text(row),
|
||||||
|
row_width,
|
||||||
|
Tone::Muted,
|
||||||
|
false,
|
||||||
|
),
|
||||||
TranscriptRowKind::Plan => {}
|
TranscriptRowKind::Plan => {}
|
||||||
}
|
}
|
||||||
if out.len() > start
|
if out.len() > start
|
||||||
@@ -399,6 +408,7 @@ pub fn copy_text(row: &TranscriptRow) -> String {
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("permission requested")
|
.unwrap_or("permission requested")
|
||||||
.into(),
|
.into(),
|
||||||
|
TranscriptRowKind::Elicitation => content_text(row),
|
||||||
TranscriptRowKind::Notice => "[notice]".into(),
|
TranscriptRowKind::Notice => "[notice]".into(),
|
||||||
TranscriptRowKind::Plan => String::new(),
|
TranscriptRowKind::Plan => String::new(),
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-23
@@ -6,11 +6,11 @@ pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
|
|||||||
read this before changing anything that renders a structured session — both
|
read this before changing anything that renders a structured session — both
|
||||||
the web and TUI renderers are written against it.
|
the web and TUI renderers are written against it.
|
||||||
|
|
||||||
It covers the eight transcript row kinds, what the daemon decides versus what
|
It covers the nine transcript row kinds, what the daemon decides versus what
|
||||||
a client decides, the two projections and their delta/cursor semantics, the
|
a client decides, the two projections and their delta/cursor semantics, the
|
||||||
ACP handshake and the capabilities it declines, the adapter's install
|
ACP handshake and the capabilities it declines, the adapter's install
|
||||||
command, and the approval nonce rules. **Non-goal: the detached runner.** It
|
command, the approval nonce rules, and the elicitation flow. **Non-goal: the
|
||||||
does not exist yet, and nothing here describes it.
|
detached runner.** It does not exist yet, and nothing here describes it.
|
||||||
|
|
||||||
## What a structured session is
|
## What a structured session is
|
||||||
|
|
||||||
@@ -88,10 +88,12 @@ declares, deliberately, almost nothing:
|
|||||||
Declining all three keeps the daemon out of the file/terminal surface
|
Declining all three keeps the daemon out of the file/terminal surface
|
||||||
entirely — the adapter already has direct access to the session's `cwd`, and
|
entirely — the adapter already has direct access to the session's `cwd`, and
|
||||||
routing every read, write, and shell command through the daemon would only
|
routing every read, write, and shell command through the daemon would only
|
||||||
add a hop with no new capability. Every other capability namespace ACP
|
add a hop with no new capability.
|
||||||
defines (session extensions, auth, elicitation) is simply left at its
|
|
||||||
default; the daemon does not advertise support because neither supported
|
The daemon does declare `elicitation.form` support. This tells the adapter
|
||||||
adapter needs it, not because it was weighed and refused.
|
that form-mode elicitation (`CreateElicitationRequest`) will be handled and
|
||||||
|
resolved by the operator, rather than declined outright. See
|
||||||
|
[Elicitations](#elicitations) below.
|
||||||
|
|
||||||
The one client method that *is* implemented for real is
|
The one client method that *is* implemented for real is
|
||||||
`session/request_permission` (#139) — see [Approvals](#approvals) below.
|
`session/request_permission` (#139) — see [Approvals](#approvals) below.
|
||||||
@@ -111,12 +113,12 @@ in the projections below.
|
|||||||
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
|
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
|
||||||
shell commands, file changes, MCP calls, web search, image generation/view,
|
shell commands, file changes, MCP calls, web search, image generation/view,
|
||||||
reasoning, plans, and token usage into standard ACP chunks and tool calls;
|
reasoning, plans, and token usage into standard ACP chunks and tool calls;
|
||||||
those fold into the same eight rows. Codex's `available_commands_update` and
|
those fold into the same nine rows. Codex's `available_commands_update` and
|
||||||
`session_info_update`, like equivalent Claude updates, remain opaque
|
`session_info_update`, like equivalent Claude updates, remain opaque
|
||||||
`Notice` rows. Its `session/load` capability is required for daemon recovery
|
`Notice` rows. Its `session/load` capability is required for daemon recovery
|
||||||
just as it is for Claude.
|
just as it is for Claude.
|
||||||
|
|
||||||
## The eight transcript row kinds
|
## The nine transcript row kinds
|
||||||
|
|
||||||
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
|
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
|
||||||
transcript is written in. Every row has a stable `id` (so later deltas can
|
transcript is written in. Every row has a stable `id` (so later deltas can
|
||||||
@@ -132,6 +134,7 @@ crate.
|
|||||||
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
|
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
|
||||||
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
|
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
|
||||||
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
|
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
|
||||||
|
| `Elicitation` | A daemon-originated elicitation request (`CreateElicitationRequest`), serialized without its nonce. Carries `message` and `requestedSchema` (a JSON Schema object with typed properties). | `elicitation:<uuid>` — minted fresh per request, same lifecycle as `Approval`. |
|
||||||
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
|
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
|
||||||
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
|
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
|
||||||
|
|
||||||
@@ -139,7 +142,7 @@ crate.
|
|||||||
`agent-client-protocol` dependency's schema already deserializes (it
|
`agent-client-protocol` dependency's schema already deserializes (it
|
||||||
currently folds `available_commands_update` and `session_info_update`
|
currently folds `available_commands_update` and `session_info_update`
|
||||||
there) but the projection's `match` doesn't fold into one of the other
|
there) but the projection's `match` doesn't fold into one of the other
|
||||||
seven kinds. It is not a wire-level forward-compatibility seam:
|
eight kinds. It is not a wire-level forward-compatibility seam:
|
||||||
`SessionUpdate` is an internally-tagged enum with no unknown-variant
|
`SessionUpdate` is an internally-tagged enum with no unknown-variant
|
||||||
fallback, so a `session/update` whose `sessionUpdate` tag this build's
|
fallback, so a `session/update` whose `sessionUpdate` tag this build's
|
||||||
dependency has never heard of — including a variant gated behind an
|
dependency has never heard of — including a variant gated behind an
|
||||||
@@ -174,6 +177,8 @@ choices, not daemon ones.
|
|||||||
- Approval nonce minting, secrecy, and the single-active-approval rule (see
|
- Approval nonce minting, secrecy, and the single-active-approval rule (see
|
||||||
below) — a client never sees an ACP permission request before the daemon
|
below) — a client never sees an ACP permission request before the daemon
|
||||||
has already turned it into an `Approval` row and a nonce.
|
has already turned it into an `Approval` row and a nonce.
|
||||||
|
- Elicitation nonce minting and the single-active-elicitation rule (see
|
||||||
|
[Elicitations](#elicitations)) — same lifecycle as approvals.
|
||||||
- What happens to an ACP update variant the dependency decodes but nobody has
|
- What happens to an ACP update variant the dependency decodes but nobody has
|
||||||
taught the projection to special-case (falls back to `Notice` rather than
|
taught the projection to special-case (falls back to `Notice` rather than
|
||||||
erroring). A variant the dependency itself can't decode is a deserialization
|
erroring). A variant the dependency itself can't decode is a deserialization
|
||||||
@@ -195,7 +200,8 @@ choices, not daemon ones.
|
|||||||
text → group → text → group rather than lumping every tool in a turn into
|
text → group → text → group rather than lumping every tool in a turn into
|
||||||
one box. Text and thought rows stay outside groups as standalone transcript
|
one box. Text and thought rows stay outside groups as standalone transcript
|
||||||
items — the operator sees all reasoning without expanding. Resolved
|
items — the operator sees all reasoning without expanding. Resolved
|
||||||
approval rows between tools within a run are absorbed into the group. The
|
approval and elicitation rows between tools within a run are absorbed into
|
||||||
|
the group. The
|
||||||
collapsed header reads "N tool calls · Label 'arg'" where the label and
|
collapsed header reads "N tool calls · Label 'arg'" where the label and
|
||||||
argument come from the last tool row's `splitToolTitle` — the operator
|
argument come from the last tool row's `splitToolTitle` — the operator
|
||||||
sees which command is current at a glance. Opening the group reveals tool
|
sees which command is current at a glance. Opening the group reveals tool
|
||||||
@@ -213,6 +219,9 @@ choices, not daemon ones.
|
|||||||
- How the operator is asked to resolve an `Approval` row — the daemon only
|
- How the operator is asked to resolve an `Approval` row — the daemon only
|
||||||
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
||||||
never the UI that solicits it.
|
never the UI that solicits it.
|
||||||
|
- How the operator is asked to resolve an `Elicitation` row — the daemon
|
||||||
|
exposes the three actions (`Accept` with content / `Decline` / `Cancel`);
|
||||||
|
the client decides the form layout and button treatment.
|
||||||
- Scrolling, collapsing, and every other presentation-only behaviour.
|
- Scrolling, collapsing, and every other presentation-only behaviour.
|
||||||
|
|
||||||
## The two projections
|
## The two projections
|
||||||
@@ -225,8 +234,8 @@ exposes two independent views, both defined in `maestro-protocol`:
|
|||||||
It is intentionally always the *whole* transcript, not a windowed one: a
|
It is intentionally always the *whole* transcript, not a windowed one: a
|
||||||
tool row patched much later must still find its original entry.
|
tool row patched much later must still find its original entry.
|
||||||
- **`ReducedState`** — one whole-session snapshot folded over every event
|
- **`ReducedState`** — one whole-session snapshot folded over every event
|
||||||
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
|
seen: `turn` (the per-turn indicators above), `pending_approval`,
|
||||||
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
`pending_elicitation`, `plan`, `usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
||||||
always full-history, never a delta — a client that only wants "is a turn
|
always full-history, never a delta — a client that only wants "is a turn
|
||||||
active right now" never has to replay anything to answer that.
|
active right now" never has to replay anything to answer that.
|
||||||
|
|
||||||
@@ -323,6 +332,37 @@ pending decision, entirely on the daemon side:
|
|||||||
permanent one (`RejectAlways`) only when that is the sole rejection option
|
permanent one (`RejectAlways`) only when that is the sole rejection option
|
||||||
offered; if neither is present, it answers `Cancelled`.
|
offered; if neither is present, it answers `Cancelled`.
|
||||||
|
|
||||||
|
## Elicitations
|
||||||
|
|
||||||
|
An ACP `elicitation/create` callback becomes an `Elicitation` row plus a
|
||||||
|
pending decision, mirroring the approval lifecycle:
|
||||||
|
|
||||||
|
- **Generation.** The daemon mints a nonce per request (`Uuid::new_v4()`),
|
||||||
|
same as approvals.
|
||||||
|
- **Live-only placement.** The nonce lives in `pending_elicitations` and in
|
||||||
|
`ReducedState.pending_elicitation.nonce`, never in the row's `data`.
|
||||||
|
- **One live elicitation at a time.** A new elicitation cancels any
|
||||||
|
prior pending one before installing itself.
|
||||||
|
- **Resolution.** `POST /api/sessions/:id/structured/elicitations/:nonce`
|
||||||
|
accepts `{ "action": "accept", "content": { ... } }`,
|
||||||
|
`{ "action": "decline" }`, or `{ "action": "cancel" }`. The endpoint
|
||||||
|
always answers `204` for a known, live structured session, same as
|
||||||
|
approvals. The three actions map to ACP's `ElicitationAction::Accept`
|
||||||
|
(with the filled-in values as `ElicitationContentValue` entries),
|
||||||
|
`Decline`, and `Cancel`.
|
||||||
|
- **Cancellation.** Turn completion, session cancel, and fatal errors
|
||||||
|
cancel pending elicitations as `Cancel`, same as approvals.
|
||||||
|
- **Schema.** The row's `data.requestedSchema` is a JSON Schema object
|
||||||
|
whose properties describe the fields the agent wants filled:
|
||||||
|
`StringPropertySchema` (with optional `enum`/`oneOf` for multiple-choice),
|
||||||
|
`BooleanPropertySchema`, `NumberPropertySchema`, `IntegerPropertySchema`.
|
||||||
|
Claude Code's common case is a single-property string enum — a
|
||||||
|
multiple-choice question with titled options.
|
||||||
|
- **Rendering.** Clients render the `message` as the question and the schema
|
||||||
|
properties as form fields or button groups. The web client detects the
|
||||||
|
single-property string enum fast path and renders titled buttons. Resolved
|
||||||
|
elicitations collapse to a compact one-liner.
|
||||||
|
|
||||||
## Prompt and cancel: bounded liveness
|
## Prompt and cancel: bounded liveness
|
||||||
|
|
||||||
The web composer accepts up to ten files through its attachment picker, file
|
The web composer accepts up to ten files through its attachment picker, file
|
||||||
@@ -364,8 +404,8 @@ allowed to claim the daemon's idea of "the active turn."
|
|||||||
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
||||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
||||||
operator's input. If that append fails, the prompt is not dispatched.
|
operator's input. If that append fails, the prompt is not dispatched.
|
||||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
- Cancel sends `session/cancel`, clears any pending approval and elicitation
|
||||||
and ends the turn's client-visible lifecycle immediately. The prompt queue
|
as `Cancelled`, and ends the turn's client-visible lifecycle immediately. The prompt queue
|
||||||
is preserved: after the cancelled turn settles, the queued prompts drain as
|
is preserved: after the cancelled turn settles, the queued prompts drain as
|
||||||
the next turn. If the session dies instead (settlement timeout), the queue
|
the next turn. If the session dies instead (settlement timeout), the queue
|
||||||
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
|
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
|
||||||
@@ -390,8 +430,8 @@ directory: the sidecar itself (`<id>.json`), plus two next to it:
|
|||||||
|
|
||||||
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
|
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
|
||||||
ACP `SessionNotification` (kept in its original JSON so old logs stay
|
ACP `SessionNotification` (kept in its original JSON so old logs stay
|
||||||
readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
|
readable) or a `{"maestroEvent": "user"|"approval"|"elicitation", "row": …}`
|
||||||
for a daemon-generated row. Both share the same 1-based line-position cursor. A
|
envelope for a daemon-generated row. Both share the same 1-based line-position cursor. A
|
||||||
line a hard kill left half-written is terminated before the next append, so
|
line a hard kill left half-written is terminated before the next append, so
|
||||||
it keeps its position and is skipped as malformed rather than corrupting
|
it keeps its position and is skipped as malformed rather than corrupting
|
||||||
what follows. An append that fails *while the daemon is running* — a
|
what follows. An append that fails *while the daemon is running* — a
|
||||||
@@ -453,12 +493,13 @@ itself failing) leaves it `Dead` with the failure reason as
|
|||||||
`activity_detail` — the same "tell the operator what to fix" pattern as a
|
`activity_detail` — the same "tell the operator what to fix" pattern as a
|
||||||
missing adapter binary.
|
missing adapter binary.
|
||||||
|
|
||||||
Pending approvals and their nonces never survive any of this. They live only
|
Pending approvals and elicitations and their nonces never survive any of
|
||||||
in the in-memory `pending_approvals` map and the live `ReducedState`, both
|
this. They live only in the in-memory `pending_approvals` /
|
||||||
gone the instant the daemon that held them exits. Whatever ACP permission
|
`pending_elicitations` maps and the live `ReducedState`, both gone the
|
||||||
request was mid-flight when the old daemon died was already answered
|
instant the daemon that held them exits. Whatever ACP permission or
|
||||||
`Cancelled` on that daemon's own shutdown path, so a resumed session always
|
elicitation request was mid-flight when the old daemon died was already
|
||||||
starts with no pending approval.
|
answered `Cancelled` on that daemon's own shutdown path, so a resumed
|
||||||
|
session always starts with no pending approval or elicitation.
|
||||||
|
|
||||||
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
|
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
|
||||||
now that structured sessions exist alongside terminal ones.
|
now that structured sessions exist alongside terminal ones.
|
||||||
|
|||||||
+18
-1
@@ -12,6 +12,7 @@ import type {
|
|||||||
RecentDirs,
|
RecentDirs,
|
||||||
Session,
|
Session,
|
||||||
StructuredApprovalDecision,
|
StructuredApprovalDecision,
|
||||||
|
StructuredElicitationAction,
|
||||||
StructuredConfigRequest,
|
StructuredConfigRequest,
|
||||||
StructuredPromptRequest,
|
StructuredPromptRequest,
|
||||||
Uuid,
|
Uuid,
|
||||||
@@ -39,7 +40,7 @@ export class ApiRequestError extends Error {
|
|||||||
|
|
||||||
async function jsonOrError<T>(res: Response): Promise<T> {
|
async function jsonOrError<T>(res: Response): Promise<T> {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (res.status === 204) return undefined as unknown as T;
|
if (res.status === 204 || res.status === 202) return undefined as unknown as T;
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -219,4 +220,20 @@ export async function resolveStructuredApproval(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a pending elicitation. Same idempotency as approvals. */
|
||||||
|
export async function resolveStructuredElicitation(
|
||||||
|
id: Uuid,
|
||||||
|
nonce: string,
|
||||||
|
action: StructuredElicitationAction,
|
||||||
|
): Promise<void> {
|
||||||
|
await jsonOrError(
|
||||||
|
await fetch(routes.structuredElicitation(id, nonce), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(action),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
|
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { activeTab, type Dir, type Node } from "../../layout.js";
|
|||||||
import { usePreview } from "../../hooks/usePreview.js";
|
import { usePreview } from "../../hooks/usePreview.js";
|
||||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||||
import type { Session, Uuid } from "../../types.js";
|
import type { Session, Uuid } from "../../types.js";
|
||||||
|
import { requestToggleDrawer } from "../../uiEvents.js";
|
||||||
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
||||||
import { PaneTitle } from "./PaneTitle.js";
|
import { PaneTitle } from "./PaneTitle.js";
|
||||||
import { TabBar } from "./TabBar.js";
|
import { TabBar } from "./TabBar.js";
|
||||||
@@ -204,6 +205,14 @@ function Pane({
|
|||||||
<div
|
<div
|
||||||
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
|
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sessions-btn mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||||
|
title="Sessions"
|
||||||
|
onClick={() => requestToggleDrawer()}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
{leaf.sessionId ? (
|
{leaf.sessionId ? (
|
||||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// An `Elicitation` row. The daemon mints the nonce; this only solicits one
|
||||||
|
// of the three possible actions (accept, decline, cancel).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AcpElicitationProperty,
|
||||||
|
AcpElicitationSchema,
|
||||||
|
StructuredElicitationAction,
|
||||||
|
} from "../../types.js";
|
||||||
|
|
||||||
|
export interface ElicitationViewProps {
|
||||||
|
/** The `CreateElicitationRequest` the row carries — serialized without
|
||||||
|
* its nonce, which never leaves the daemon's live wire. */
|
||||||
|
request: {
|
||||||
|
message?: string;
|
||||||
|
requestedSchema?: AcpElicitationSchema;
|
||||||
|
};
|
||||||
|
/** Whether this is the one live elicitation. */
|
||||||
|
pending: boolean;
|
||||||
|
onDecide: (action: StructuredElicitationAction) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For a single-property string enum schema (the common Claude Code
|
||||||
|
* multiple-choice case), extract the property key and its options. */
|
||||||
|
function singleSelectEnum(
|
||||||
|
schema: AcpElicitationSchema | undefined,
|
||||||
|
): { key: string; options: Array<{ value: string; title: string; description?: string }> } | null {
|
||||||
|
if (!schema) return null;
|
||||||
|
const keys = Object.keys(schema.properties);
|
||||||
|
if (keys.length !== 1) return null;
|
||||||
|
const key = keys[0];
|
||||||
|
const prop = schema.properties[key];
|
||||||
|
if (prop.oneOf && prop.oneOf.length > 0) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
options: prop.oneOf.map((o) => ({
|
||||||
|
value: o.const,
|
||||||
|
title: o.title,
|
||||||
|
description: o.description,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prop.enum && prop.enum.length > 0) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
options: prop.enum.map((v) => ({ value: v, title: v })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ElicitationView({ request, pending, onDecide }: ElicitationViewProps) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, string | number | boolean>>({});
|
||||||
|
const message = request.message ?? "Input requested";
|
||||||
|
const schema = request.requestedSchema;
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
return <div className="st-row st-row-elicitation st-elicitation-compact">{message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decide = (action: StructuredElicitationAction) => {
|
||||||
|
setBusy(true);
|
||||||
|
onDecide(action).catch(() => setBusy(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const singleSelect = singleSelectEnum(schema);
|
||||||
|
|
||||||
|
// Fast path: single-property string enum → button group (the common
|
||||||
|
// Claude Code multiple-choice question).
|
||||||
|
if (singleSelect) {
|
||||||
|
return (
|
||||||
|
<div className="st-row st-row-elicitation">
|
||||||
|
<div className="st-elicitation">
|
||||||
|
<div className="st-elicitation-title">
|
||||||
|
<span aria-hidden="true">?</span> {message}
|
||||||
|
</div>
|
||||||
|
<div className="st-elicitation-options">
|
||||||
|
{singleSelect.options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className="act"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
decide({ action: "accept", content: { [singleSelect.key]: opt.value } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{opt.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="st-elicitation-acts">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="act deny"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => decide({ action: "decline" })}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// General form: render inputs for each property.
|
||||||
|
const properties = schema?.properties ?? {};
|
||||||
|
const propertyKeys = Object.keys(properties);
|
||||||
|
const requiredSet = new Set(schema?.required ?? []);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
decide({ action: "accept", content: formValues });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (key: string, value: string | number | boolean) => {
|
||||||
|
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="st-row st-row-elicitation">
|
||||||
|
<div className="st-elicitation">
|
||||||
|
<div className="st-elicitation-title">
|
||||||
|
<span aria-hidden="true">?</span> {message}
|
||||||
|
</div>
|
||||||
|
<div className="st-elicitation-form">
|
||||||
|
{propertyKeys.map((key) => {
|
||||||
|
const prop = properties[key];
|
||||||
|
return (
|
||||||
|
<ElicitationField
|
||||||
|
key={key}
|
||||||
|
name={key}
|
||||||
|
prop={prop}
|
||||||
|
required={requiredSet.has(key)}
|
||||||
|
disabled={busy}
|
||||||
|
value={formValues[key]}
|
||||||
|
onChange={(v) => updateField(key, v)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="st-elicitation-acts">
|
||||||
|
<button type="button" className="act primary" disabled={busy} onClick={handleSubmit}>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="act deny"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => decide({ action: "decline" })}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ElicitationField({
|
||||||
|
name,
|
||||||
|
prop,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
prop: AcpElicitationProperty;
|
||||||
|
required: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
value: string | number | boolean | undefined;
|
||||||
|
onChange: (value: string | number | boolean) => void;
|
||||||
|
}) {
|
||||||
|
const label = prop.title ?? name;
|
||||||
|
const type = prop.type ?? "string";
|
||||||
|
|
||||||
|
if (type === "boolean") {
|
||||||
|
return (
|
||||||
|
<label className="st-elicitation-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={typeof value === "boolean" ? value : (prop.default === true)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "number" || type === "integer") {
|
||||||
|
return (
|
||||||
|
<label className="st-elicitation-field">
|
||||||
|
{label}
|
||||||
|
{required && <span className="st-elicitation-required">*</span>}
|
||||||
|
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={type === "integer" ? 1 : "any"}
|
||||||
|
min={prop.minimum}
|
||||||
|
max={prop.maximum}
|
||||||
|
value={typeof value === "number" ? value : ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(type === "integer" ? parseInt(e.target.value) : parseFloat(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: string
|
||||||
|
return (
|
||||||
|
<label className="st-elicitation-field">
|
||||||
|
{label}
|
||||||
|
{required && <span className="st-elicitation-required">*</span>}
|
||||||
|
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={typeof prop.default === "string" ? prop.default : undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -170,16 +170,14 @@ describe("tool rows and activity groups", () => {
|
|||||||
snapshot(socket, [
|
snapshot(socket, [
|
||||||
chunk("user:1", "user", "go"),
|
chunk("user:1", "user", "go"),
|
||||||
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
||||||
chunk("agent:1", "agent_text", "still working"),
|
|
||||||
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
|
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
|
||||||
chunk("agent:2", "agent_text", "the answer"),
|
chunk("agent:1", "agent_text", "the answer"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const head = screen.getByText(/^2 tool calls/).closest("button")!;
|
const head = screen.getByText(/^2 tool calls/).closest("button")!;
|
||||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||||
// The final answer stays outside the group; the intermediate text is in it.
|
// The final answer stays outside the group.
|
||||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
||||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
|
|
||||||
|
|
||||||
fireEvent.click(head);
|
fireEvent.click(head);
|
||||||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
cancelStructuredTurn,
|
cancelStructuredTurn,
|
||||||
resolveStructuredApproval,
|
resolveStructuredApproval,
|
||||||
|
resolveStructuredElicitation,
|
||||||
sendStructuredPrompt,
|
sendStructuredPrompt,
|
||||||
setStructuredConfig,
|
setStructuredConfig,
|
||||||
uploadStructuredFile,
|
uploadStructuredFile,
|
||||||
@@ -29,6 +30,7 @@ import { formatContextTokens, formatElapsed } from "./render.js";
|
|||||||
import type {
|
import type {
|
||||||
AcpSessionConfigOption,
|
AcpSessionConfigOption,
|
||||||
StructuredApprovalDecision,
|
StructuredApprovalDecision,
|
||||||
|
StructuredElicitationAction,
|
||||||
TranscriptRow as Row,
|
TranscriptRow as Row,
|
||||||
Uuid,
|
Uuid,
|
||||||
} from "../../types.js";
|
} from "../../types.js";
|
||||||
@@ -36,6 +38,7 @@ import { Composer } from "./Composer.js";
|
|||||||
import {
|
import {
|
||||||
commandsFromTranscript,
|
commandsFromTranscript,
|
||||||
pendingApprovalRowId as pendingApprovalRowIdOf,
|
pendingApprovalRowId as pendingApprovalRowIdOf,
|
||||||
|
pendingElicitationRowId as pendingElicitationRowIdOf,
|
||||||
transcriptItems,
|
transcriptItems,
|
||||||
turnIsActive,
|
turnIsActive,
|
||||||
} from "./model.js";
|
} from "./model.js";
|
||||||
@@ -124,9 +127,10 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
|||||||
|
|
||||||
const turnActive = turnIsActive(state);
|
const turnActive = turnIsActive(state);
|
||||||
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
|
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
|
||||||
|
const pendingElicitationRowId = useMemo(() => pendingElicitationRowIdOf(rows, state), [rows, state]);
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => transcriptItems(rows, pendingRowId, turnActive),
|
() => transcriptItems(rows, pendingRowId, turnActive, pendingElicitationRowId),
|
||||||
[rows, pendingRowId, turnActive],
|
[rows, pendingRowId, turnActive, pendingElicitationRowId],
|
||||||
);
|
);
|
||||||
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
|
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
|
||||||
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
|
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
|
||||||
@@ -193,6 +197,20 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
|||||||
[sessionId, state.pending_approval?.nonce, pendingRowId],
|
[sessionId, state.pending_approval?.nonce, pendingRowId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const decideElicitation = useCallback(
|
||||||
|
async (rowId: string, action: StructuredElicitationAction): Promise<void> => {
|
||||||
|
const nonce = state.pending_elicitation?.nonce;
|
||||||
|
if (!nonce || rowId !== pendingElicitationRowId) return;
|
||||||
|
try {
|
||||||
|
await resolveStructuredElicitation(sessionId, nonce, action);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(String(e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessionId, state.pending_elicitation?.nonce, pendingElicitationRowId],
|
||||||
|
);
|
||||||
|
|
||||||
const dispatchPrompt = useCallback(
|
const dispatchPrompt = useCallback(
|
||||||
(text: string, files: File[]) => {
|
(text: string, files: File[]) => {
|
||||||
const generation = ++submission.current;
|
const generation = ++submission.current;
|
||||||
@@ -311,6 +329,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
|||||||
row={row}
|
row={row}
|
||||||
pendingApproval={row.id === pendingRowId}
|
pendingApproval={row.id === pendingRowId}
|
||||||
onDecide={(decision) => decide(row.id, decision)}
|
onDecide={(decision) => decide(row.id, decision)}
|
||||||
|
pendingElicitation={row.id === pendingElicitationRowId}
|
||||||
|
onElicitationDecide={(action) => decideElicitation(row.id, action)}
|
||||||
expanded={expandedRows.has(row.id)}
|
expanded={expandedRows.has(row.id)}
|
||||||
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
|
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
|
||||||
imageUrls={imageUrls.current}
|
imageUrls={imageUrls.current}
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import { useEffect, useRef, type ReactNode } from "react";
|
|||||||
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
|
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
|
||||||
import type {
|
import type {
|
||||||
AcpContentBlock,
|
AcpContentBlock,
|
||||||
|
AcpElicitationSchema,
|
||||||
AcpPermissionOption,
|
AcpPermissionOption,
|
||||||
AcpToolCallContent,
|
AcpToolCallContent,
|
||||||
AcpToolCallLike,
|
AcpToolCallLike,
|
||||||
StructuredApprovalDecision,
|
StructuredApprovalDecision,
|
||||||
|
StructuredElicitationAction,
|
||||||
TranscriptRow as Row,
|
TranscriptRow as Row,
|
||||||
TurnMeta,
|
TurnMeta,
|
||||||
} from "../../types.js";
|
} from "../../types.js";
|
||||||
import { ApprovalView } from "./ApprovalView.js";
|
import { ApprovalView } from "./ApprovalView.js";
|
||||||
|
import { ElicitationView } from "./ElicitationView.js";
|
||||||
import { Copyable } from "./Copyable.js";
|
import { Copyable } from "./Copyable.js";
|
||||||
import { DiffView } from "./DiffView.js";
|
import { DiffView } from "./DiffView.js";
|
||||||
import {
|
import {
|
||||||
@@ -337,6 +340,9 @@ export interface TranscriptRowProps {
|
|||||||
* rules). Every other approval row is history. */
|
* rules). Every other approval row is history. */
|
||||||
pendingApproval?: boolean;
|
pendingApproval?: boolean;
|
||||||
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
|
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||||
|
/** This row is the one live elicitation. */
|
||||||
|
pendingElicitation?: boolean;
|
||||||
|
onElicitationDecide?: (action: StructuredElicitationAction) => Promise<void>;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
imageUrls?: Map<string, string>;
|
imageUrls?: Map<string, string>;
|
||||||
@@ -348,6 +354,8 @@ export function TranscriptRow({
|
|||||||
row,
|
row,
|
||||||
pendingApproval = false,
|
pendingApproval = false,
|
||||||
onDecide,
|
onDecide,
|
||||||
|
pendingElicitation = false,
|
||||||
|
onElicitationDecide,
|
||||||
expanded = false,
|
expanded = false,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
imageUrls,
|
imageUrls,
|
||||||
@@ -374,6 +382,16 @@ export function TranscriptRow({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case "elicitation": {
|
||||||
|
const request = (row.data ?? {}) as { message?: string; requestedSchema?: AcpElicitationSchema };
|
||||||
|
return (
|
||||||
|
<ElicitationView
|
||||||
|
request={request}
|
||||||
|
pending={pendingElicitation}
|
||||||
|
onDecide={onElicitationDecide ?? (() => Promise.resolve())}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
case "plan":
|
case "plan":
|
||||||
return null;
|
return null;
|
||||||
case "notice": {
|
case "notice": {
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState)
|
|||||||
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
|
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same for elicitations — one live at a time. */
|
||||||
|
export function pendingElicitationRowId(rows: TranscriptRow[], state: ReducedState): string | null {
|
||||||
|
return state.pending_elicitation ? lastRowIdOfKind(rows, "elicitation") : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
|
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
|
||||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||||
return null;
|
return null;
|
||||||
@@ -168,6 +173,7 @@ export function activityGroupsByTurn(
|
|||||||
rows: TranscriptRow[],
|
rows: TranscriptRow[],
|
||||||
pendingApprovalId: string | null,
|
pendingApprovalId: string | null,
|
||||||
_turnActive: boolean,
|
_turnActive: boolean,
|
||||||
|
pendingElicitationId: string | null = null,
|
||||||
): Map<string, ActivityGroup> {
|
): Map<string, ActivityGroup> {
|
||||||
const groups = new Map<string, ActivityGroup>();
|
const groups = new Map<string, ActivityGroup>();
|
||||||
let turnId = "before-first-message";
|
let turnId = "before-first-message";
|
||||||
@@ -204,8 +210,10 @@ export function activityGroupsByTurn(
|
|||||||
flushRun();
|
flushRun();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
const isResolvedInteraction =
|
||||||
if (isResolvedApproval) {
|
(row.kind === "approval" && row.id !== pendingApprovalId) ||
|
||||||
|
(row.kind === "elicitation" && row.id !== pendingElicitationId);
|
||||||
|
if (isResolvedInteraction) {
|
||||||
runPending.push(row);
|
runPending.push(row);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -239,8 +247,9 @@ export function transcriptItems(
|
|||||||
rows: TranscriptRow[],
|
rows: TranscriptRow[],
|
||||||
pendingApprovalId: string | null,
|
pendingApprovalId: string | null,
|
||||||
turnActive: boolean,
|
turnActive: boolean,
|
||||||
|
pendingElicitationId: string | null = null,
|
||||||
): TranscriptItem[] {
|
): TranscriptItem[] {
|
||||||
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
|
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive, pendingElicitationId);
|
||||||
// Reverse index: row id → group id that contains it.
|
// Reverse index: row id → group id that contains it.
|
||||||
const rowToGroup = new Map<string, string>();
|
const rowToGroup = new Map<string, string>();
|
||||||
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
|
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
|
||||||
@@ -259,9 +268,10 @@ export function transcriptItems(
|
|||||||
}
|
}
|
||||||
if (row.kind === "plan") continue;
|
if (row.kind === "plan") continue;
|
||||||
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
|
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
|
||||||
// A resolved approval outside a group adds nothing the tool row does not
|
// A resolved approval/elicitation outside a group adds nothing the tool
|
||||||
// already say. A pending one always renders.
|
// row does not already say. A pending one always renders.
|
||||||
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
|
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
|
||||||
|
if (row.kind === "elicitation" && row.id !== pendingElicitationId) continue;
|
||||||
items.push({ type: "row", row });
|
items.push({ type: "row", row });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -913,6 +913,90 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Elicitation: the structured question the agent asks the operator.
|
||||||
|
Visually parallel to approvals but accent-tinted instead of warn-tinted. */
|
||||||
|
.st-elicitation {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
background: color-mix(in oklab, var(--accent) 10%, var(--surface));
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-title {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-options {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-acts {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-field input[type="text"],
|
||||||
|
.st-elicitation-field input[type="number"] {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-field input[type="checkbox"] {
|
||||||
|
margin-right: var(--sp-1);
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-required {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-desc {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-elicitation-compact {
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-row-elicitation:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
.act {
|
.act {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export function structuredApproval(id: Uuid, nonce: string): string {
|
|||||||
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
|
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/elicitations/:nonce` — answer an
|
||||||
|
* elicitation request. */
|
||||||
|
export function structuredElicitation(id: Uuid, nonce: string): string {
|
||||||
|
return `${session(id)}/structured/elicitations/${encodeURIComponent(nonce)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||||
export function withPathQuery(route: string, value: string): string {
|
export function withPathQuery(route: string, value: string): string {
|
||||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||||
|
|||||||
@@ -4651,6 +4651,12 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 859px) {
|
@media (max-width: 859px) {
|
||||||
|
/* On mobile the sidebar drawer is the only session-switching surface, so
|
||||||
|
the tab bar is redundant — hide it unconditionally. */
|
||||||
|
.mv-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
||||||
.app-shell > .sidebar {
|
.app-shell > .sidebar {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ export type TranscriptRowKind =
|
|||||||
| "tool_call"
|
| "tool_call"
|
||||||
| "tool_diff"
|
| "tool_diff"
|
||||||
| "approval"
|
| "approval"
|
||||||
|
| "elicitation"
|
||||||
| "plan"
|
| "plan"
|
||||||
| "notice";
|
| "notice";
|
||||||
|
|
||||||
@@ -435,6 +436,7 @@ export interface TurnState {
|
|||||||
export interface ReducedState {
|
export interface ReducedState {
|
||||||
turn: TurnState;
|
turn: TurnState;
|
||||||
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
|
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
|
||||||
|
pending_elicitation?: { nonce: string; message?: string; schema?: AcpElicitationSchema } | null;
|
||||||
plan?: AcpPlan | null;
|
plan?: AcpPlan | null;
|
||||||
usage?: AcpUsage | null;
|
usage?: AcpUsage | null;
|
||||||
context?: AcpUsage | null;
|
context?: AcpUsage | null;
|
||||||
@@ -486,6 +488,35 @@ export interface AcpSessionConfigOption {
|
|||||||
* string (a bracketless Rust enum), not `{decision: ...}`. */
|
* string (a bracketless Rust enum), not `{decision: ...}`. */
|
||||||
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
|
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/elicitations/:nonce` body. */
|
||||||
|
export type StructuredElicitationAction =
|
||||||
|
| { action: "accept"; content: Record<string, string | number | boolean> }
|
||||||
|
| { action: "decline" }
|
||||||
|
| { action: "cancel" };
|
||||||
|
|
||||||
|
/** Property schema within an ACP elicitation request. */
|
||||||
|
export interface AcpElicitationProperty {
|
||||||
|
type?: "string" | "number" | "integer" | "boolean";
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
default?: string | number | boolean;
|
||||||
|
enum?: string[];
|
||||||
|
oneOf?: Array<{ const: string; title: string; description?: string }>;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `requested_schema` carried by an ACP `elicitation/create` request. */
|
||||||
|
export interface AcpElicitationSchema {
|
||||||
|
type?: "object";
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
properties: Record<string, AcpElicitationProperty>;
|
||||||
|
required?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
|
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
|
||||||
|
|
||||||
export interface AcpEmbeddedResource {
|
export interface AcpEmbeddedResource {
|
||||||
|
|||||||
Reference in New Issue
Block a user