feat: add structured session picker
This commit is contained in:
Generated
+1
@@ -1477,6 +1477,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"libc",
|
"libc",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ description = "ACP client role: one claude-code-acp child per structured session
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
agent-client-protocol.workspace = true
|
agent-client-protocol.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
+302
-14
@@ -42,13 +42,16 @@ 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, ContentBlock, FileSystemCapabilities, Implementation,
|
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
|
||||||
InitializeRequest, LoadSessionRequest, NewSessionRequest, PromptRequest,
|
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
|
||||||
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId,
|
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
|
||||||
SessionNotification,
|
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, 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};
|
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::AsyncBufReadExt;
|
use tokio::io::AsyncBufReadExt;
|
||||||
use tokio::sync::{mpsc, oneshot, watch};
|
use tokio::sync::{mpsc, oneshot, watch};
|
||||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||||
@@ -78,6 +81,75 @@ pub const TERM_GRACE: Duration = Duration::from_secs(2);
|
|||||||
|
|
||||||
/// Bytes of the child's stderr kept for the failure message.
|
/// Bytes of the child's stderr kept for the failure message.
|
||||||
const STDERR_TAIL_BYTES: usize = 4096;
|
const STDERR_TAIL_BYTES: usize = 4096;
|
||||||
|
const LEGACY_MODEL_CONFIG_ID: &str = "__maestro_legacy_model";
|
||||||
|
const LEGACY_MODE_CONFIG_ID: &str = "__maestro_legacy_mode";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||||
|
#[request(method = "session/new", response = ExtendedNewSessionResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExtendedNewSessionRequest {
|
||||||
|
cwd: PathBuf,
|
||||||
|
mcp_servers: Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExtendedNewSessionResponse {
|
||||||
|
session_id: SessionId,
|
||||||
|
#[serde(default)]
|
||||||
|
modes: Option<SessionModeState>,
|
||||||
|
#[serde(default)]
|
||||||
|
config_options: Option<Vec<SessionConfigOption>>,
|
||||||
|
#[serde(default)]
|
||||||
|
models: Option<LegacyModelState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||||
|
#[request(method = "session/load", response = ExtendedLoadSessionResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExtendedLoadSessionRequest {
|
||||||
|
session_id: SessionId,
|
||||||
|
cwd: PathBuf,
|
||||||
|
mcp_servers: Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExtendedLoadSessionResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
modes: Option<SessionModeState>,
|
||||||
|
#[serde(default)]
|
||||||
|
config_options: Option<Vec<SessionConfigOption>>,
|
||||||
|
#[serde(default)]
|
||||||
|
models: Option<LegacyModelState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LegacyModelState {
|
||||||
|
current_model_id: String,
|
||||||
|
available_models: Vec<LegacyModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LegacyModel {
|
||||||
|
model_id: String,
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||||
|
#[request(method = "session/set_model", response = LegacySetModelResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LegacySetModelRequest {
|
||||||
|
session_id: SessionId,
|
||||||
|
model_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||||
|
struct LegacySetModelResponse {}
|
||||||
|
|
||||||
/// What [`spawn`] needs to start one adapter.
|
/// What [`spawn`] needs to start one adapter.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -168,6 +240,11 @@ enum Command {
|
|||||||
turn: u64,
|
turn: u64,
|
||||||
done: oneshot::Sender<Result<(), String>>,
|
done: oneshot::Sender<Result<(), String>>,
|
||||||
},
|
},
|
||||||
|
SetConfig {
|
||||||
|
config_id: String,
|
||||||
|
value: SessionConfigOptionValue,
|
||||||
|
done: oneshot::Sender<Result<(), String>>,
|
||||||
|
},
|
||||||
Close,
|
Close,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +378,23 @@ impl AcpSession {
|
|||||||
Ok(done_rx)
|
Ok(done_rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change one option advertised by the ACP agent.
|
||||||
|
pub fn set_config(
|
||||||
|
&self,
|
||||||
|
config_id: String,
|
||||||
|
value: SessionConfigOptionValue,
|
||||||
|
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
||||||
|
let (done_tx, done_rx) = oneshot::channel();
|
||||||
|
self.commands
|
||||||
|
.send(Command::SetConfig {
|
||||||
|
config_id,
|
||||||
|
value,
|
||||||
|
done: done_tx,
|
||||||
|
})
|
||||||
|
.map_err(|_| "ACP connection is closed".to_string())?;
|
||||||
|
Ok(done_rx)
|
||||||
|
}
|
||||||
|
|
||||||
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
|
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
|
||||||
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
|
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
|
||||||
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
|
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
|
||||||
@@ -513,6 +607,91 @@ fn client_capabilities() -> ClientCapabilities {
|
|||||||
.terminal(false)
|
.terminal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn advertised_config_options(
|
||||||
|
mut options: Vec<SessionConfigOption>,
|
||||||
|
modes: Option<&SessionModeState>,
|
||||||
|
models: Option<&LegacyModelState>,
|
||||||
|
) -> Vec<SessionConfigOption> {
|
||||||
|
let has_model = options
|
||||||
|
.iter()
|
||||||
|
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model)));
|
||||||
|
let has_mode = options
|
||||||
|
.iter()
|
||||||
|
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Mode)));
|
||||||
|
if let Some(models) = models.filter(|_| !has_model) {
|
||||||
|
let choices = models
|
||||||
|
.available_models
|
||||||
|
.iter()
|
||||||
|
.map(|model| {
|
||||||
|
let mut option =
|
||||||
|
SessionConfigSelectOption::new(model.model_id.clone(), &model.name);
|
||||||
|
option.description.clone_from(&model.description);
|
||||||
|
option
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
options.push(
|
||||||
|
SessionConfigOption::select(
|
||||||
|
LEGACY_MODEL_CONFIG_ID,
|
||||||
|
"Model",
|
||||||
|
models.current_model_id.clone(),
|
||||||
|
choices,
|
||||||
|
)
|
||||||
|
.category(SessionConfigOptionCategory::Model),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(modes) = modes.filter(|_| !has_mode) {
|
||||||
|
let choices = modes
|
||||||
|
.available_modes
|
||||||
|
.iter()
|
||||||
|
.map(|mode| {
|
||||||
|
let mut option = SessionConfigSelectOption::new(mode.id.0.clone(), &mode.name);
|
||||||
|
option.description.clone_from(&mode.description);
|
||||||
|
option
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
options.push(
|
||||||
|
SessionConfigOption::select(
|
||||||
|
LEGACY_MODE_CONFIG_ID,
|
||||||
|
"Mode",
|
||||||
|
modes.current_mode_id.0.clone(),
|
||||||
|
choices,
|
||||||
|
)
|
||||||
|
.category(SessionConfigOptionCategory::Mode),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
options
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_config_value(
|
||||||
|
options: &Arc<Mutex<Vec<SessionConfigOption>>>,
|
||||||
|
config_id: &str,
|
||||||
|
value: &SessionConfigOptionValue,
|
||||||
|
) -> Vec<SessionConfigOption> {
|
||||||
|
let mut options = options.lock().expect("config options");
|
||||||
|
if let Some(option) = options
|
||||||
|
.iter_mut()
|
||||||
|
.find(|option| option.id.0.as_ref() == config_id)
|
||||||
|
{
|
||||||
|
match (&mut option.kind, value) {
|
||||||
|
(SessionConfigKind::Select(select), SessionConfigOptionValue::ValueId { value }) => {
|
||||||
|
select.current_value = value.clone();
|
||||||
|
}
|
||||||
|
(SessionConfigKind::Boolean(boolean), SessionConfigOptionValue::Boolean { value }) => {
|
||||||
|
boolean.current_value = *value;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> SessionNotification {
|
||||||
|
SessionNotification::new(
|
||||||
|
SessionId::new(session_id),
|
||||||
|
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_connection<T>(
|
async fn run_connection<T>(
|
||||||
transport: T,
|
transport: T,
|
||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
@@ -534,6 +713,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 received_updates = updates_tx.clone();
|
||||||
let result = Client
|
let result = Client
|
||||||
.builder()
|
.builder()
|
||||||
.name("maestro")
|
.name("maestro")
|
||||||
@@ -544,7 +724,7 @@ async fn run_connection<T>(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// A dropped receiver only means nobody wants updates any more.
|
// A dropped receiver only means nobody wants updates any more.
|
||||||
let _ = updates_tx.send(notification);
|
let _ = received_updates.send(notification);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
agent_client_protocol::on_receive_notification!(),
|
agent_client_protocol::on_receive_notification!(),
|
||||||
@@ -589,7 +769,7 @@ async fn run_connection<T>(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let session_id = match load_session {
|
let (session_id, modes, initial_options, models) = match load_session {
|
||||||
Some(existing) => {
|
Some(existing) => {
|
||||||
if !initialized.agent_capabilities.load_session {
|
if !initialized.agent_capabilities.load_session {
|
||||||
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
|
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
|
||||||
@@ -601,7 +781,11 @@ async fn run_connection<T>(
|
|||||||
let (loaded_tx, loaded_rx) = oneshot::channel();
|
let (loaded_tx, loaded_rx) = oneshot::channel();
|
||||||
let replaying_done = replaying.clone();
|
let replaying_done = replaying.clone();
|
||||||
if let Err(e) = cx
|
if let Err(e) = cx
|
||||||
.send_request(LoadSessionRequest::new(existing.clone(), cwd))
|
.send_request(ExtendedLoadSessionRequest {
|
||||||
|
session_id: SessionId::new(existing.as_str()),
|
||||||
|
cwd,
|
||||||
|
mcp_servers: vec![],
|
||||||
|
})
|
||||||
.on_receiving_result(move |result| {
|
.on_receiving_result(move |result| {
|
||||||
replaying_done.store(false, Ordering::SeqCst);
|
replaying_done.store(false, Ordering::SeqCst);
|
||||||
async move {
|
async move {
|
||||||
@@ -613,8 +797,8 @@ async fn run_connection<T>(
|
|||||||
let _ = ready_tx.send(Err(ready_error(e)));
|
let _ = ready_tx.send(Err(ready_error(e)));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
match loaded_rx.await {
|
let loaded = match loaded_rx.await {
|
||||||
Ok(Ok(_)) => {}
|
Ok(Ok(loaded)) => loaded,
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
let _ = ready_tx.send(Err(ready_error(e)));
|
let _ = ready_tx.send(Err(ready_error(e)));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -625,21 +809,48 @@ async fn run_connection<T>(
|
|||||||
)));
|
)));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
existing
|
(existing, loaded.modes, loaded.config_options.unwrap_or_default(), loaded.models)
|
||||||
}
|
}
|
||||||
None => match cx
|
None => match cx
|
||||||
.send_request(NewSessionRequest::new(cwd))
|
.send_request(ExtendedNewSessionRequest {
|
||||||
|
cwd,
|
||||||
|
mcp_servers: vec![],
|
||||||
|
})
|
||||||
.block_task()
|
.block_task()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(created) => created.session_id.0.to_string(),
|
Ok(created) => (
|
||||||
|
created.session_id.0.to_string(),
|
||||||
|
created.modes,
|
||||||
|
created.config_options.unwrap_or_default(),
|
||||||
|
created.models,
|
||||||
|
),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = ready_tx.send(Err(ready_error(e)));
|
let _ = ready_tx.send(Err(ready_error(e)));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
let config_options = Arc::new(Mutex::new(advertised_config_options(
|
||||||
|
initial_options,
|
||||||
|
modes.as_ref(),
|
||||||
|
models.as_ref(),
|
||||||
|
)));
|
||||||
|
if let Some(modes) = &modes {
|
||||||
|
let _ = updates_tx.send(SessionNotification::new(
|
||||||
|
SessionId::new(session_id.as_str()),
|
||||||
|
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||||
|
modes.current_mode_id.clone(),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !config_options.lock().expect("config options").is_empty() {
|
||||||
|
let _ = updates_tx.send(config_notification(
|
||||||
|
&session_id,
|
||||||
|
config_options.lock().expect("config options").clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let _ = ready_tx.send(Ok(session_id.clone()));
|
let _ = ready_tx.send(Ok(session_id.clone()));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -689,6 +900,83 @@ async fn run_connection<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(Command::SetConfig { config_id, value, done }) => {
|
||||||
|
let done = Arc::new(Mutex::new(Some(done)));
|
||||||
|
let callback_done = done.clone();
|
||||||
|
let callback_options = config_options.clone();
|
||||||
|
let callback_updates = updates_tx.clone();
|
||||||
|
let callback_session = session_id.clone();
|
||||||
|
let callback_config_id = config_id.clone();
|
||||||
|
let callback_value = value.clone();
|
||||||
|
let sent = if config_id == LEGACY_MODEL_CONFIG_ID {
|
||||||
|
let Some(model_id) = value.as_value_id() else {
|
||||||
|
let _ = done.lock().expect("config sender").take()
|
||||||
|
.map(|done| done.send(Err("model must be a string value".into())));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
cx.send_request(LegacySetModelRequest {
|
||||||
|
session_id: SessionId::new(session_id.as_str()),
|
||||||
|
model_id: model_id.0.to_string(),
|
||||||
|
}).on_receiving_result(move |result| async move {
|
||||||
|
if result.is_ok() {
|
||||||
|
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||||
|
let _ = callback_updates.send(config_notification(&callback_session, options));
|
||||||
|
}
|
||||||
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
} else if config_id == LEGACY_MODE_CONFIG_ID {
|
||||||
|
let Some(mode_id) = value.as_value_id() else {
|
||||||
|
let _ = done.lock().expect("config sender").take()
|
||||||
|
.map(|done| done.send(Err("mode must be a string value".into())));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
cx.send_request(SetSessionModeRequest::new(
|
||||||
|
SessionId::new(session_id.as_str()),
|
||||||
|
mode_id.0.to_string(),
|
||||||
|
)).on_receiving_result(move |result| async move {
|
||||||
|
if result.is_ok() {
|
||||||
|
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||||
|
let _ = callback_updates.send(config_notification(&callback_session, options));
|
||||||
|
let _ = callback_updates.send(SessionNotification::new(
|
||||||
|
SessionId::new(callback_session.as_str()),
|
||||||
|
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||||
|
callback_value.as_value_id().expect("checked mode value").0.clone(),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
cx.send_request(SetSessionConfigOptionRequest::new(
|
||||||
|
SessionId::new(session_id.as_str()),
|
||||||
|
config_id,
|
||||||
|
value,
|
||||||
|
))
|
||||||
|
.on_receiving_result(move |result| async move {
|
||||||
|
if let Ok(response) = &result {
|
||||||
|
let mut current = callback_options.lock().expect("config options");
|
||||||
|
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
|
||||||
|
current.extend(response.config_options.clone());
|
||||||
|
let _ = callback_updates.send(config_notification(&callback_session, current.clone()));
|
||||||
|
}
|
||||||
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Err(error) = sent {
|
||||||
|
if let Some(done) = done.lock().expect("config sender").take() {
|
||||||
|
let _ = done.send(Err(error.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(Command::Close) | None => break,
|
Some(Command::Close) | None => break,
|
||||||
},
|
},
|
||||||
_ = cx.incoming_closed() => break,
|
_ = cx.incoming_closed() => break,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Environment knobs, all optional:
|
|||||||
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
|
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
|
||||||
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
|
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
|
||||||
JSON-RPC error
|
JSON-RPC error
|
||||||
|
FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options
|
||||||
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
|
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
|
||||||
callback until a later prompt
|
callback until a later prompt
|
||||||
|
|
||||||
@@ -78,6 +79,41 @@ prompt_failures = 0
|
|||||||
pending_prompt_id = None
|
pending_prompt_id = None
|
||||||
pending_permission_id = None
|
pending_permission_id = None
|
||||||
delayed_permission = False
|
delayed_permission = False
|
||||||
|
current_model = "opus"
|
||||||
|
current_mode = "default"
|
||||||
|
current_effort = "high"
|
||||||
|
|
||||||
|
|
||||||
|
def settings_response(include_session=False):
|
||||||
|
result = {}
|
||||||
|
if include_session:
|
||||||
|
result["sessionId"] = session_id
|
||||||
|
if os.environ.get("FAKE_ACP_SETTINGS") == "1":
|
||||||
|
result.update({
|
||||||
|
"models": {
|
||||||
|
"currentModelId": current_model,
|
||||||
|
"availableModels": [
|
||||||
|
{"modelId": "opus", "name": "Opus"},
|
||||||
|
{"modelId": "sonnet", "name": "Sonnet"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"currentModeId": current_mode,
|
||||||
|
"availableModes": [
|
||||||
|
{"id": "default", "name": "Default"},
|
||||||
|
{"id": "plan", "name": "Plan"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"configOptions": [{
|
||||||
|
"id": "effort", "name": "Effort", "category": "thought_level",
|
||||||
|
"type": "select", "currentValue": current_effort,
|
||||||
|
"options": [
|
||||||
|
{"value": "low", "name": "Low"},
|
||||||
|
{"value": "high", "name": "High"},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def chunk(text):
|
def chunk(text):
|
||||||
@@ -139,7 +175,7 @@ for raw in sys.stdin:
|
|||||||
while gate and not os.path.exists(gate):
|
while gate and not os.path.exists(gate):
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
|
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
|
||||||
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}})
|
send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)})
|
||||||
send(chunk("hello from fake"))
|
send(chunk("hello from fake"))
|
||||||
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
|
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
@@ -152,8 +188,18 @@ for raw in sys.stdin:
|
|||||||
continue
|
continue
|
||||||
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
|
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
|
||||||
send(chunk(f"replayed {i + 1}"))
|
send(chunk(f"replayed {i + 1}"))
|
||||||
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
send({"jsonrpc": "2.0", "id": rid, "result": settings_response()})
|
||||||
send(chunk("hello after load"))
|
send(chunk("hello after load"))
|
||||||
|
elif method == "session/set_model":
|
||||||
|
current_model = params.get("modelId")
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||||
|
elif method == "session/set_mode":
|
||||||
|
current_mode = params.get("modeId")
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||||
|
elif method == "session/set_config_option":
|
||||||
|
current_effort = params.get("value")
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {
|
||||||
|
"configOptions": settings_response()["configOptions"]}})
|
||||||
elif method == "session/cancel":
|
elif method == "session/cancel":
|
||||||
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
|
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
|
||||||
send(chunk("final on cancel"))
|
send(chunk("final on cancel"))
|
||||||
|
|||||||
@@ -180,6 +180,52 @@ async fn session_updates_reach_the_taken_receiver() {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn advertised_settings_are_normalized_and_mutable() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake.config(cwd.path());
|
||||||
|
config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into());
|
||||||
|
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||||
|
let mut updates = session.take_updates().expect("updates");
|
||||||
|
|
||||||
|
let initial = loop {
|
||||||
|
let update = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
||||||
|
.await
|
||||||
|
.expect("settings update")
|
||||||
|
.expect("channel open");
|
||||||
|
if let SessionUpdate::ConfigOptionUpdate(options) = update.update {
|
||||||
|
break options.config_options;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(initial.len(), 3);
|
||||||
|
assert!(initial
|
||||||
|
.iter()
|
||||||
|
.any(|option| option.id.0.as_ref() == "effort"));
|
||||||
|
assert!(initial
|
||||||
|
.iter()
|
||||||
|
.any(|option| option.id.0.as_ref() == "__maestro_legacy_model"));
|
||||||
|
assert!(initial
|
||||||
|
.iter()
|
||||||
|
.any(|option| option.id.0.as_ref() == "__maestro_legacy_mode"));
|
||||||
|
|
||||||
|
let changed = session
|
||||||
|
.set_config(
|
||||||
|
"__maestro_legacy_model".into(),
|
||||||
|
maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"),
|
||||||
|
)
|
||||||
|
.expect("queue model change");
|
||||||
|
changed
|
||||||
|
.await
|
||||||
|
.expect("response delivered")
|
||||||
|
.expect("model changed");
|
||||||
|
fake.wait_for_log("recv session/set_model").await;
|
||||||
|
|
||||||
|
session
|
||||||
|
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn terminate_sends_cancel_then_sigterm() {
|
async fn terminate_sends_cancel_then_sigterm() {
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ 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, StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
|
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::session::{QueueAcpPrompt, SessionError, SessionManager};
|
use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
|
||||||
use crate::sidecar::EventCursor;
|
use crate::sidecar::EventCursor;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -70,6 +71,7 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
||||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||||
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
||||||
|
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
||||||
.route(
|
.route(
|
||||||
routes::patterns::STRUCTURED_APPROVAL,
|
routes::patterns::STRUCTURED_APPROVAL,
|
||||||
post(structured_approval),
|
post(structured_approval),
|
||||||
@@ -472,6 +474,37 @@ async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn structured_config(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<StructuredConfigRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match s
|
||||||
|
.manager
|
||||||
|
.set_acp_config(id, body.config_id, body.value)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(SetAcpConfig::Updated) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(SetAcpConfig::InvalidValue) => error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_config_value",
|
||||||
|
"configuration values must be strings or booleans",
|
||||||
|
),
|
||||||
|
Ok(SetAcpConfig::Unavailable) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Ok(SetAcpConfig::Failed(message)) => {
|
||||||
|
error_response(StatusCode::BAD_GATEWAY, "acp_config_failed", &message)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn structured_approval(
|
async fn structured_approval(
|
||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
Path((id, nonce)): Path<(Uuid, String)>,
|
Path((id, nonce)): Path<(Uuid, String)>,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ impl Projection {
|
|||||||
SessionUpdate::ConfigOptionUpdate(options) => {
|
SessionUpdate::ConfigOptionUpdate(options) => {
|
||||||
let value = to_value(options);
|
let value = to_value(options);
|
||||||
self.state.model = model_from_options(&value).or_else(|| self.state.model.clone());
|
self.state.model = model_from_options(&value).or_else(|| self.state.model.clone());
|
||||||
|
self.state.config_options = Some(value);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
// ACP deliberately adds notification variants over time. Known but
|
// ACP deliberately adds notification variants over time. Known but
|
||||||
@@ -721,6 +722,10 @@ mod tests {
|
|||||||
assert_eq!(state.context.as_ref().unwrap()["size"], 200);
|
assert_eq!(state.context.as_ref().unwrap()["size"], 200);
|
||||||
assert_eq!(state.mode.as_deref(), Some("plan"));
|
assert_eq!(state.mode.as_deref(), Some("plan"));
|
||||||
assert_eq!(state.model.as_deref(), Some("opus"));
|
assert_eq!(state.model.as_deref(), Some("opus"));
|
||||||
|
assert_eq!(
|
||||||
|
state.config_options.as_ref().unwrap()["configOptions"][0]["currentValue"],
|
||||||
|
"opus"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.plan.as_ref().unwrap()["entries"],
|
state.plan.as_ref().unwrap()["entries"],
|
||||||
serde_json::json!([])
|
serde_json::json!([])
|
||||||
|
|||||||
@@ -218,6 +218,15 @@ pub enum QueueAcpPrompt {
|
|||||||
Failed(String),
|
Failed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of changing a live structured session option.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SetAcpConfig {
|
||||||
|
Updated,
|
||||||
|
InvalidValue,
|
||||||
|
Unavailable,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
impl SessionError {
|
impl SessionError {
|
||||||
pub fn to_error_info(&self) -> ErrorInfo {
|
pub fn to_error_info(&self) -> ErrorInfo {
|
||||||
let code = match self {
|
let code = match self {
|
||||||
@@ -850,6 +859,48 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change one option on a live ACP session and wait for the agent to
|
||||||
|
/// acknowledge it. The adapter's returned option set is projected by the
|
||||||
|
/// normal event pump before this request completes.
|
||||||
|
pub async fn set_acp_config(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
config_id: String,
|
||||||
|
value: serde_json::Value,
|
||||||
|
) -> Result<SetAcpConfig, SessionError> {
|
||||||
|
let (acp, 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(SetAcpConfig::Unavailable);
|
||||||
|
}
|
||||||
|
(record.acp.clone(), record.acp_events.clone())
|
||||||
|
};
|
||||||
|
let (Some(acp), Some(stream)) = (acp, stream) else {
|
||||||
|
return Ok(SetAcpConfig::Unavailable);
|
||||||
|
};
|
||||||
|
let value = match value {
|
||||||
|
serde_json::Value::String(value) => {
|
||||||
|
maestro_acp::schema::SessionConfigOptionValue::value_id(value)
|
||||||
|
}
|
||||||
|
serde_json::Value::Bool(value) => {
|
||||||
|
maestro_acp::schema::SessionConfigOptionValue::boolean(value)
|
||||||
|
}
|
||||||
|
_ => return Ok(SetAcpConfig::InvalidValue),
|
||||||
|
};
|
||||||
|
let operation = stream.lock().await.operation.clone();
|
||||||
|
let _operation = operation.lock().await;
|
||||||
|
let done = match acp.set_config(config_id, value) {
|
||||||
|
Ok(done) => done,
|
||||||
|
Err(message) => return Ok(SetAcpConfig::Failed(message)),
|
||||||
|
};
|
||||||
|
match done.await {
|
||||||
|
Ok(Ok(())) => Ok(SetAcpConfig::Updated),
|
||||||
|
Ok(Err(message)) => Ok(SetAcpConfig::Failed(message)),
|
||||||
|
Err(_) => Ok(SetAcpConfig::Failed("ACP connection is closed".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancel an ACP turn and immediately clear its client-facing lifecycle.
|
/// Cancel an ACP turn and immediately clear its client-facing lifecycle.
|
||||||
pub async fn cancel_acp_turn(&self, id: Uuid) -> Result<bool, SessionError> {
|
pub async fn cancel_acp_turn(&self, id: Uuid) -> Result<bool, SessionError> {
|
||||||
let (acp, stream) = {
|
let (acp, stream) = {
|
||||||
|
|||||||
@@ -846,6 +846,8 @@ pub struct ReducedState {
|
|||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub mode: Option<String>,
|
pub mode: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config_options: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server messages sent over a structured-session WebSocket.
|
/// Server messages sent over a structured-session WebSocket.
|
||||||
@@ -871,6 +873,13 @@ pub struct StructuredPromptRequest {
|
|||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HTTP body for `POST /api/sessions/:id/structured/config`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct StructuredConfigRequest {
|
||||||
|
pub config_id: String,
|
||||||
|
pub value: Value,
|
||||||
|
}
|
||||||
|
|
||||||
/// Operator decision for a pending ACP permission request.
|
/// Operator decision for a pending ACP permission request.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ pub fn structured_cancel(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}/structured/cancel")
|
format!("{SESSIONS}/{id}/structured/cancel")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/structured/config` — change an ACP session option.
|
||||||
|
pub fn structured_config(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/config")
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission.
|
/// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission.
|
||||||
pub fn structured_approval(id: Uuid, nonce: &str) -> String {
|
pub fn structured_approval(id: Uuid, nonce: &str) -> String {
|
||||||
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
|
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
|
||||||
@@ -109,6 +114,7 @@ pub mod patterns {
|
|||||||
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
||||||
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
||||||
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_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
RecentDirs,
|
RecentDirs,
|
||||||
Session,
|
Session,
|
||||||
StructuredApprovalDecision,
|
StructuredApprovalDecision,
|
||||||
|
StructuredConfigRequest,
|
||||||
StructuredPromptRequest,
|
StructuredPromptRequest,
|
||||||
Uuid,
|
Uuid,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
@@ -93,6 +94,21 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Change one option advertised by a live ACP session. */
|
||||||
|
export async function setStructuredConfig(
|
||||||
|
id: Uuid,
|
||||||
|
req: StructuredConfigRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
await jsonOrError(
|
||||||
|
await fetch(routes.structuredConfig(id), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Report whether this client's pane is the one currently showing `id` — not
|
/** Report whether this client's pane is the one currently showing `id` — not
|
||||||
* merely highlighted in a sidebar. The daemon uses this to clear an
|
* merely highlighted in a sidebar. The daemon uses this to clear an
|
||||||
* `AwaitingInput` glow once the pane has been looked at long enough
|
* `AwaitingInput` glow once the pane has been looked at long enough
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export function structuredCancel(id: Uuid): string {
|
|||||||
return `${session(id)}/structured/cancel`;
|
return `${session(id)}/structured/cancel`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/config` — change an ACP session option. */
|
||||||
|
export function structuredConfig(id: Uuid): string {
|
||||||
|
return `${session(id)}/structured/config`;
|
||||||
|
}
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission
|
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission
|
||||||
* request. */
|
* request. */
|
||||||
export function structuredApproval(id: Uuid, nonce: string): string {
|
export function structuredApproval(id: Uuid, nonce: string): string {
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ vi.mock("./api.js", async () => {
|
|||||||
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
||||||
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
||||||
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
||||||
|
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js";
|
import {
|
||||||
|
cancelStructuredTurn,
|
||||||
|
resolveStructuredApproval,
|
||||||
|
sendStructuredPrompt,
|
||||||
|
setStructuredConfig,
|
||||||
|
} from "./api.js";
|
||||||
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
||||||
import type { CatalogStore } from "./events.js";
|
import type { CatalogStore } from "./events.js";
|
||||||
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
|
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
|
||||||
@@ -142,6 +148,53 @@ describe("structured connection: data flow", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders advertised session settings and sends changed values", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "reduced_state",
|
||||||
|
state: {
|
||||||
|
...idleState,
|
||||||
|
config_options: {
|
||||||
|
configOptions: [
|
||||||
|
{
|
||||||
|
id: "model",
|
||||||
|
name: "Model",
|
||||||
|
category: "model",
|
||||||
|
type: "select",
|
||||||
|
currentValue: "opus",
|
||||||
|
options: [
|
||||||
|
{ value: "opus", name: "Opus" },
|
||||||
|
{ value: "sonnet", name: "Sonnet" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "effort",
|
||||||
|
name: "Effort",
|
||||||
|
category: "thought_level",
|
||||||
|
type: "select",
|
||||||
|
currentValue: "high",
|
||||||
|
options: [{ value: "high", name: "High" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(conn.el.querySelector(".structured-config-summary")?.textContent).toBe("Opus · High");
|
||||||
|
(conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click();
|
||||||
|
const selects = conn.el.querySelectorAll<HTMLSelectElement>(".structured-config-select");
|
||||||
|
expect(selects).toHaveLength(2);
|
||||||
|
selects[0].value = "sonnet";
|
||||||
|
selects[0].dispatchEvent(new Event("change"));
|
||||||
|
expect(setStructuredConfig).toHaveBeenCalledWith("s1", {
|
||||||
|
config_id: "model",
|
||||||
|
value: "sonnet",
|
||||||
|
});
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("applies plan append, patch, and remove without disturbing surrounding rows", () => {
|
it("applies plan append, patch, and remove without disturbing surrounding rows", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
|
|||||||
+120
-8
@@ -10,6 +10,7 @@ import {
|
|||||||
cancelStructuredTurn,
|
cancelStructuredTurn,
|
||||||
resolveStructuredApproval,
|
resolveStructuredApproval,
|
||||||
sendStructuredPrompt,
|
sendStructuredPrompt,
|
||||||
|
setStructuredConfig,
|
||||||
structuredWsUrl,
|
structuredWsUrl,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { requestToggleDrawer } from "./drawer.js";
|
import { requestToggleDrawer } from "./drawer.js";
|
||||||
@@ -19,6 +20,8 @@ import type { PaneConnection } from "./pane.js";
|
|||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||||
import { buildToolGroup, buildTranscriptRow } from "./structuredRender.js";
|
import { buildToolGroup, buildTranscriptRow } from "./structuredRender.js";
|
||||||
import type {
|
import type {
|
||||||
|
AcpSessionConfigChoice,
|
||||||
|
AcpSessionConfigOption,
|
||||||
ReducedState,
|
ReducedState,
|
||||||
StructuredApprovalDecision,
|
StructuredApprovalDecision,
|
||||||
StructuredEvent,
|
StructuredEvent,
|
||||||
@@ -69,7 +72,11 @@ export function createStructuredConnection(
|
|||||||
<header class="attach-header">
|
<header class="attach-header">
|
||||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||||
<span class="attach-title" title="${paneTitleAttr(sessionId, info)}">${paneTitleHtml(sessionId, info)}</span>
|
<span class="attach-title" title="${paneTitleAttr(sessionId, info)}">${paneTitleHtml(sessionId, info)}</span>
|
||||||
<span class="st-pills"></span>
|
<div class="structured-config" hidden>
|
||||||
|
<button type="button" class="structured-config-trigger" aria-haspopup="true"
|
||||||
|
aria-expanded="false"><span class="structured-config-summary">Session settings</span><span class="structured-config-caret" aria-hidden="true"></span></button>
|
||||||
|
<div class="structured-config-menu" role="group" aria-label="Session settings" hidden></div>
|
||||||
|
</div>
|
||||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -94,7 +101,10 @@ export function createStructuredConnection(
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||||
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
|
const configEl = el.querySelector(".structured-config") as HTMLDivElement;
|
||||||
|
const configTrigger = el.querySelector(".structured-config-trigger") as HTMLButtonElement;
|
||||||
|
const configSummary = el.querySelector(".structured-config-summary") as HTMLSpanElement;
|
||||||
|
const configMenu = el.querySelector(".structured-config-menu") as HTMLDivElement;
|
||||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||||
const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
|
const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
|
||||||
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
||||||
@@ -128,6 +138,7 @@ export function createStructuredConnection(
|
|||||||
let state: ReducedState = { turn: idleTurn() };
|
let state: ReducedState = { turn: idleTurn() };
|
||||||
let pendingApprovalRowId: string | null = null;
|
let pendingApprovalRowId: string | null = null;
|
||||||
let availableCommands: AvailableCommand[] = [];
|
let availableCommands: AvailableCommand[] = [];
|
||||||
|
let configBusy = false;
|
||||||
let filteredCommands: AvailableCommand[] = [];
|
let filteredCommands: AvailableCommand[] = [];
|
||||||
let activeCommandIndex = -1;
|
let activeCommandIndex = -1;
|
||||||
let submissionGeneration = 0;
|
let submissionGeneration = 0;
|
||||||
@@ -364,11 +375,90 @@ export function createStructuredConnection(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPills(): void {
|
function sessionConfigOptions(): AcpSessionConfigOption[] {
|
||||||
const parts: string[] = [];
|
return state.config_options?.configOptions ?? [];
|
||||||
if (state.model) parts.push(state.model);
|
}
|
||||||
if (state.mode) parts.push(state.mode);
|
|
||||||
pillsEl.textContent = parts.join(" · ");
|
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
|
||||||
|
return (option.options ?? []).flatMap((choice) =>
|
||||||
|
"options" in choice ? choice.options : [choice],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentConfigLabel(option: AcpSessionConfigOption): string {
|
||||||
|
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
|
||||||
|
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
|
||||||
|
?? option.currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeConfig(option: AcpSessionConfigOption, value: string | boolean): Promise<void> {
|
||||||
|
configBusy = true;
|
||||||
|
renderConfig();
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
await setStructuredConfig(sessionId, { config_id: option.id, value });
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : "Could not change the session setting");
|
||||||
|
} finally {
|
||||||
|
configBusy = false;
|
||||||
|
renderConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfig(): void {
|
||||||
|
const options = sessionConfigOptions();
|
||||||
|
configEl.hidden = options.length === 0;
|
||||||
|
if (options.length === 0) {
|
||||||
|
configMenu.hidden = true;
|
||||||
|
configTrigger.setAttribute("aria-expanded", "false");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ordered = [...options].sort((a, b) => {
|
||||||
|
const rank = (category?: string | null) =>
|
||||||
|
["model", "thought_level", "mode", "model_config"].indexOf(category ?? "");
|
||||||
|
const aRank = rank(a.category);
|
||||||
|
const bRank = rank(b.category);
|
||||||
|
return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank);
|
||||||
|
});
|
||||||
|
configSummary.textContent = ordered.map(currentConfigLabel).join(" · ");
|
||||||
|
configTrigger.title = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
|
||||||
|
configTrigger.disabled = configBusy;
|
||||||
|
configMenu.replaceChildren();
|
||||||
|
for (const option of ordered) {
|
||||||
|
const row = document.createElement("label");
|
||||||
|
row.className = "structured-config-row";
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "structured-config-name";
|
||||||
|
name.textContent = option.name;
|
||||||
|
row.append(name);
|
||||||
|
if (option.type === "select") {
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.className = "structured-config-select";
|
||||||
|
select.disabled = configBusy;
|
||||||
|
for (const choice of configChoices(option)) {
|
||||||
|
const item = document.createElement("option");
|
||||||
|
item.value = choice.value;
|
||||||
|
item.textContent = choice.name;
|
||||||
|
item.title = choice.description ?? "";
|
||||||
|
select.append(item);
|
||||||
|
}
|
||||||
|
select.value = String(option.currentValue);
|
||||||
|
select.addEventListener("change", () => void changeConfig(option, select.value));
|
||||||
|
row.append(select);
|
||||||
|
} else if (option.type === "boolean") {
|
||||||
|
const toggle = document.createElement("input");
|
||||||
|
toggle.type = "checkbox";
|
||||||
|
toggle.className = "structured-config-toggle";
|
||||||
|
toggle.checked = option.currentValue === true;
|
||||||
|
toggle.disabled = configBusy;
|
||||||
|
toggle.addEventListener("change", () => void changeConfig(option, toggle.checked));
|
||||||
|
row.append(toggle);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (option.description) row.title = option.description;
|
||||||
|
configMenu.append(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComposerState(): void {
|
function renderComposerState(): void {
|
||||||
@@ -403,10 +493,31 @@ export function createStructuredConnection(
|
|||||||
if (prev !== null || newPendingId !== null) rebuildTranscript();
|
if (prev !== null || newPendingId !== null) rebuildTranscript();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
renderPills();
|
renderConfig();
|
||||||
renderComposerState();
|
renderComposerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeConfigMenu(): void {
|
||||||
|
configMenu.hidden = true;
|
||||||
|
configTrigger.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
configTrigger.addEventListener("click", () => {
|
||||||
|
const opening = configMenu.hidden;
|
||||||
|
configMenu.hidden = !opening;
|
||||||
|
configTrigger.setAttribute("aria-expanded", String(opening));
|
||||||
|
});
|
||||||
|
const closeConfigOnOutsidePress = (event: PointerEvent) => {
|
||||||
|
if (!configEl.contains(event.target as Node)) closeConfigMenu();
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", closeConfigOnOutsidePress);
|
||||||
|
configEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeConfigMenu();
|
||||||
|
configTrigger.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- error banner ----
|
// ---- error banner ----
|
||||||
let errorEl: HTMLElement | null = null;
|
let errorEl: HTMLElement | null = null;
|
||||||
function showError(message: string): void {
|
function showError(message: string): void {
|
||||||
@@ -783,6 +894,7 @@ export function createStructuredConnection(
|
|||||||
expandedGroupIds.clear();
|
expandedGroupIds.clear();
|
||||||
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||||
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||||
|
document.removeEventListener("pointerdown", closeConfigOnOutsidePress);
|
||||||
el.remove();
|
el.remove();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+102
-3
@@ -2173,12 +2173,111 @@ input.leaf-rename:focus {
|
|||||||
only lifted, card-like element on the page; everything else is either a
|
only lifted, card-like element on the page; everything else is either a
|
||||||
tinted block (user), plain text (agent), or a thin bordered row (tool). */
|
tinted block (user), plain text (agent), or a thin bordered row (tool). */
|
||||||
|
|
||||||
.st-pills {
|
.structured-config {
|
||||||
font-size: var(--fs-11);
|
position: relative;
|
||||||
|
flex: 0 1 300px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-trigger {
|
||||||
|
width: 100%;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
background: var(--surface-2);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
white-space: nowrap;
|
font: 500 var(--fs-11) / 1 var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-trigger:hover,
|
||||||
|
.structured-config-trigger[aria-expanded="true"] {
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-trigger:focus-visible,
|
||||||
|
.structured-config-select:focus-visible,
|
||||||
|
.structured-config-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-summary {
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-caret {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-right: 1.5px solid currentColor;
|
||||||
|
border-bottom: 1.5px solid currentColor;
|
||||||
|
transform: translateY(-1px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-trigger[aria-expanded="true"] .structured-config-caret {
|
||||||
|
transform: translateY(2px) rotate(225deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: calc(100% + var(--sp-2));
|
||||||
|
right: 0;
|
||||||
|
width: min(320px, 86vw);
|
||||||
|
padding: var(--sp-2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(80px, 1fr) minmax(0, 1.6fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
min-height: 34px;
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-row + .structured-config-row {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-select {
|
||||||
|
min-width: 0;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-config-toggle {
|
||||||
|
justify-self: end;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-body {
|
.structured-body {
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ export interface ReducedState {
|
|||||||
context?: AcpUsage | null;
|
context?: AcpUsage | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
mode?: string | null;
|
mode?: string | null;
|
||||||
|
config_options?: { configOptions: AcpSessionConfigOption[] } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server messages on `WS /api/structured/:id`. */
|
/** Server messages on `WS /api/structured/:id`. */
|
||||||
@@ -449,6 +450,33 @@ export interface StructuredPromptRequest {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StructuredConfigRequest {
|
||||||
|
config_id: string;
|
||||||
|
value: string | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpSessionConfigChoice {
|
||||||
|
value: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpSessionConfigGroup {
|
||||||
|
group: string;
|
||||||
|
name: string;
|
||||||
|
options: AcpSessionConfigChoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpSessionConfigOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
category?: string | null;
|
||||||
|
type: "select" | "boolean" | string;
|
||||||
|
currentValue: string | boolean;
|
||||||
|
options?: Array<AcpSessionConfigChoice | AcpSessionConfigGroup>;
|
||||||
|
}
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON
|
/** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON
|
||||||
* 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";
|
||||||
|
|||||||
Reference in New Issue
Block a user