Compare commits

...
5 Commits
Author SHA1 Message Date
Miguel Palhas fa3d6c82b2 Merge remote-tracking branch 'origin/main' into chord
ci / web (push) Failing after 26s
ci / flake (push) Successful in 4m6s
ci / rust (push) Successful in 7m29s
2026-09-12 14:10:59 +01:00
Miguel PalhasandClaude Opus 4.6 ebe25a1a55 feat(maestro): server-side prompt queue for structured sessions
Prompts sent while a turn is active are queued on the daemon instead of
in the browser's React state. The queue is persisted in the session
sidecar and drained as the next turn when the current one completes.

Fixes the bug where queued messages were delivered to the wrong session
when switching focus in the UI: the client-side queue captured the new
sessionId in its drain closure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 14:10:55 +01:00
naps62-yolo 8cbe8510ac fix(maestro): truncate long commands in tool group header (#258)
ci / web (push) Failing after 56s
ci / flake (push) Successful in 5m2s
ci / rust (push) Successful in 9m34s
2026-09-12 11:08:04 +01:00
Miguel Palhas cd5bbcfcb1 Merge remote-tracking branch 'origin/main' into timbre-2
ci / web (push) Failing after 1m7s
ci / flake (push) Successful in 3m55s
ci / rust (push) Has been cancelled
2026-09-12 10:59:49 +01:00
Miguel PalhasandClaude Opus 4.6 cbbf526fc4 fix(maestro): persist auto_approve across daemon restarts
Structured sessions spawned with auto_approve lost the flag on daemon
restart because it lived only in the in-memory SessionRecord. Add it to
the Session struct (sidecar JSON) so recovery reads it back instead of
hardcoding false.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 10:59:44 +01:00
18 changed files with 298 additions and 149 deletions
+2
View File
@@ -345,6 +345,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
} }
} }
+6 -7
View File
@@ -483,13 +483,12 @@ async fn structured_prompt(
Json(body): Json<StructuredPromptRequest>, Json(body): Json<StructuredPromptRequest>,
) -> Response { ) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await { match s.manager.queue_acp_prompt(id, body.prompt).await {
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => { Ok(QueueAcpPrompt::Dispatched) => StatusCode::NO_CONTENT.into_response(),
StatusCode::NO_CONTENT.into_response() Ok(QueueAcpPrompt::Queued) => StatusCode::ACCEPTED.into_response(),
} Ok(QueueAcpPrompt::Full) => error_response(
Ok(QueueAcpPrompt::InFlight) => error_response( StatusCode::TOO_MANY_REQUESTS,
StatusCode::CONFLICT, "queue_full",
"turn_in_flight", "the prompt queue has reached its capacity",
"the structured session already has an active turn",
), ),
Ok(QueueAcpPrompt::Unavailable) => error_response( Ok(QueueAcpPrompt::Unavailable) => error_response(
StatusCode::CONFLICT, StatusCode::CONFLICT,
+201 -57
View File
@@ -5,7 +5,7 @@
//! single point through which the API handlers and the per-session WS //! single point through which the API handlers and the per-session WS
//! handler interact with tmux. //! handler interact with tmux.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
@@ -15,8 +15,8 @@ use chrono::Utc;
use maestro_acp::{AcpAdapter, AcpSession, PermissionRequest, SpawnConfig}; use maestro_acp::{AcpAdapter, AcpSession, PermissionRequest, SpawnConfig};
use maestro_protocol::display::command_tool; use maestro_protocol::display::command_tool;
use maestro_protocol::{ use maestro_protocol::{
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session, ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, ReducedState,
SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, TranscriptRow, Session, SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, TranscriptRow,
TranscriptRowKind, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY, TranscriptRowKind, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY,
}; };
use tokio::sync::{broadcast, oneshot, watch, Mutex, Notify}; use tokio::sync::{broadcast, oneshot, watch, Mutex, Notify};
@@ -122,9 +122,6 @@ pub struct SessionRecord {
/// When `true`, the permission pump auto-responds `AllowAlways` to every /// When `true`, the permission pump auto-responds `AllowAlways` to every
/// ACP permission request without surfacing it to clients. /// ACP permission request without surfacing it to clients.
pub auto_approve: bool, pub auto_approve: bool,
/// A prompt submitted while the adapter was still booting (`Starting`).
/// The background handshake task drains this on success.
pub pending_prompt: Option<String>,
} }
impl SessionRecord { impl SessionRecord {
@@ -149,6 +146,9 @@ struct StructuredActivityWatermark {
sequence: u64, sequence: u64,
} }
/// Maximum number of prompts that can be queued while a turn is in flight.
const PROMPT_QUEUE_CAP: usize = 20;
#[derive(Debug)] #[derive(Debug)]
struct AcpEventStream { struct AcpEventStream {
log: EventLog, log: EventLog,
@@ -160,6 +160,10 @@ struct AcpEventStream {
/// 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<()>>,
dispatch: AcpDispatchState, dispatch: AcpDispatchState,
/// Server-side FIFO of prompts accepted while a turn was in flight or the
/// adapter was still booting. Drained as a single concatenated prompt when
/// the current turn completes. Persisted to the session sidecar.
prompt_queue: VecDeque<String>,
activity_sequence: u64, activity_sequence: u64,
} }
@@ -168,6 +172,23 @@ impl AcpEventStream {
self.activity_sequence += 1; self.activity_sequence += 1;
(self.activity_sequence, self.projection.activity()) (self.activity_sequence, self.projection.activity())
} }
/// The reduced state with the prompt queue overlaid. Every broadcast and
/// snapshot should use this instead of `projection.state()` directly so
/// clients always see the current queue.
fn state_with_queue(&self) -> ReducedState {
let mut state = self.projection.state();
state.queued_prompts = self.prompt_queue.iter().cloned().collect();
state
}
/// Broadcast a projection update with the prompt queue overlaid into the
/// state. Every `projected_live.send` should go through this so clients
/// never see a stale queue.
fn broadcast(&self, mut update: ProjectionUpdate) {
update.state.queued_prompts = self.prompt_queue.iter().cloned().collect();
let _ = self.projected_live.send(update);
}
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -235,11 +256,14 @@ pub enum BeginAcpTurn {
/// Result of serializing a projected turn claim with its ACP prompt enqueue. /// Result of serializing a projected turn claim with its ACP prompt enqueue.
#[derive(Debug)] #[derive(Debug)]
pub enum QueueAcpPrompt { pub enum QueueAcpPrompt {
/// The prompt was dispatched to ACP immediately (no turn was in flight).
Dispatched,
/// The prompt was accepted into the server-side queue and will be
/// dispatched when the current turn completes.
Queued, Queued,
/// The adapter is still booting; the prompt is held until it is ready.
Pending,
InFlight,
Unavailable, Unavailable,
/// The prompt queue has reached its capacity.
Full,
Failed(String), Failed(String),
} }
@@ -662,7 +686,7 @@ impl SessionManager {
let live = stream.projected_live.subscribe(); let live = stream.projected_live.subscribe();
Ok(Some(( Ok(Some((
stream.projection.snapshot(stream.log.last_seq()), stream.projection.snapshot(stream.log.last_seq()),
stream.projection.state(), stream.state_with_queue(),
live, live,
))) )))
} }
@@ -692,7 +716,7 @@ impl SessionManager {
return Ok(false); return Ok(false);
}; };
let projected = persist_turn_update(&mut stream, projected); let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected); stream.broadcast(projected);
drop(stream); drop(stream);
sync_structured_activity(&self.inner, id).await; sync_structured_activity(&self.inner, id).await;
Ok(true) Ok(true)
@@ -717,7 +741,7 @@ impl SessionManager {
return Ok(BeginAcpTurn::InFlight); return Ok(BeginAcpTurn::InFlight);
} }
let (turn, update) = stream.projection.begin_turn(); let (turn, update) = stream.projection.begin_turn();
let _ = stream.projected_live.send(update); stream.broadcast(update);
drop(stream); drop(stream);
sync_structured_activity(&self.inner, id).await; sync_structured_activity(&self.inner, id).await;
Ok(BeginAcpTurn::Started(acp, turn)) Ok(BeginAcpTurn::Started(acp, turn))
@@ -735,13 +759,28 @@ impl SessionManager {
text: String, text: String,
) -> Result<QueueAcpPrompt, SessionError> { ) -> Result<QueueAcpPrompt, SessionError> {
let (acp, stream) = { let (acp, stream) = {
let mut sessions = self.inner.sessions.lock().await; let sessions = self.inner.sessions.lock().await;
let record = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?; let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
// While the adapter is booting, stash the prompt for the
// background handshake task to drain on success.
if record.is_starting_structured() { if record.is_starting_structured() {
record.pending_prompt = Some(text); // Adapter still booting — queue the prompt for drain on boot
return Ok(QueueAcpPrompt::Pending); // success. The AcpEventStream exists, just no ACP handle yet.
let stream = record
.acp_events
.clone()
.expect("structured session has events at Starting");
let mut stream = stream.lock().await;
if stream.prompt_queue.len() >= PROMPT_QUEUE_CAP {
return Ok(QueueAcpPrompt::Full);
}
stream.prompt_queue.push_back(text);
let update = ProjectionUpdate {
transcript: None,
state: stream.projection.state(),
};
stream.broadcast(update);
drop(stream);
self.persist_prompt_queue(id).await;
return Ok(QueueAcpPrompt::Queued);
} }
if !record.is_live_structured() { if !record.is_live_structured() {
return Ok(QueueAcpPrompt::Unavailable); return Ok(QueueAcpPrompt::Unavailable);
@@ -762,12 +801,37 @@ impl SessionManager {
} }
if let Some(turn) = stream.dispatch.queued_turn { if let Some(turn) = stream.dispatch.queued_turn {
if stream.dispatch.cancelled_turn != Some(turn) { if stream.dispatch.cancelled_turn != Some(turn) {
return Ok(QueueAcpPrompt::InFlight); // Turn is in flight — queue instead of rejecting.
if stream.prompt_queue.len() >= PROMPT_QUEUE_CAP {
return Ok(QueueAcpPrompt::Full);
}
stream.prompt_queue.push_back(text);
let update = ProjectionUpdate {
transcript: None,
state: stream.projection.state(),
};
stream.broadcast(update);
drop(stream);
self.persist_prompt_queue(id).await;
return Ok(QueueAcpPrompt::Queued);
} }
Some((turn, stream.dispatch.settled.clone())) Some((turn, stream.dispatch.settled.clone()))
} else { } else {
if stream.projection.turn_active() { if stream.projection.turn_active() {
return Ok(QueueAcpPrompt::InFlight); // Projection says active but dispatch says idle —
// queue rather than rejecting.
if stream.prompt_queue.len() >= PROMPT_QUEUE_CAP {
return Ok(QueueAcpPrompt::Full);
}
stream.prompt_queue.push_back(text);
let update = ProjectionUpdate {
transcript: None,
state: stream.projection.state(),
};
stream.broadcast(update);
drop(stream);
self.persist_prompt_queue(id).await;
return Ok(QueueAcpPrompt::Queued);
} }
let row = TranscriptRow { let row = TranscriptRow {
id: format!("user:{}", Uuid::new_v4()), id: format!("user:{}", Uuid::new_v4()),
@@ -789,9 +853,9 @@ impl SessionManager {
let logged = LoggedEvent { seq, event }; let logged = LoggedEvent { seq, event };
let projected = stream.projection.restore_row(row); let projected = stream.projection.restore_row(row);
let _ = stream.live.send(logged); let _ = stream.live.send(logged);
let _ = stream.projected_live.send(projected); stream.broadcast(projected);
let (turn, update) = stream.projection.begin_turn(); let (turn, update) = stream.projection.begin_turn();
let _ = stream.projected_live.send(update); stream.broadcast(update);
match acp.prompt(text.clone(), turn) { match acp.prompt(text.clone(), turn) {
Ok(done) => { Ok(done) => {
stream.dispatch.queued_turn = Some(turn); stream.dispatch.queued_turn = Some(turn);
@@ -809,7 +873,7 @@ impl SessionManager {
if let Ok(true) = self.claim_autoname(id).await { if let Ok(true) = self.claim_autoname(id).await {
crate::autoname::spawn_naming(self.clone(), id, text); crate::autoname::spawn_naming(self.clone(), id, text);
} }
return Ok(QueueAcpPrompt::Queued); return Ok(QueueAcpPrompt::Dispatched);
} }
Err(message) => { Err(message) => {
cancel_pending_approvals(&mut stream); cancel_pending_approvals(&mut stream);
@@ -820,7 +884,7 @@ impl SessionManager {
"newly claimed turn remains current while stream is locked", "newly claimed turn remains current while stream is locked",
); );
let projected = persist_turn_update(&mut stream, projected); let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected); stream.broadcast(projected);
drop(stream); drop(stream);
sync_structured_activity(&self.inner, id).await; sync_structured_activity(&self.inner, id).await;
return Ok(QueueAcpPrompt::Failed(message)); return Ok(QueueAcpPrompt::Failed(message));
@@ -840,6 +904,27 @@ impl SessionManager {
} }
} }
/// Persist the current prompt queue to the session sidecar.
async fn persist_prompt_queue(&self, id: Uuid) {
let stream_arc = {
let sessions = self.inner.sessions.lock().await;
sessions.get(&id).and_then(|r| r.acp_events.clone())
};
let Some(stream_arc) = stream_arc else { return };
let queue: Vec<String> = stream_arc
.lock()
.await
.prompt_queue
.iter()
.cloned()
.collect();
let mut sessions = self.inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.queued_prompts = queue;
let _ = self.inner.sidecar.write(&record.session);
}
}
async fn finish_queued_acp_turn(&self, id: Uuid, turn: u64, lifecycle: TurnLifecycle) { async fn finish_queued_acp_turn(&self, id: Uuid, turn: u64, lifecycle: TurnLifecycle) {
let stream = self let stream = self
.inner .inner
@@ -860,16 +945,33 @@ impl SessionManager {
stream.dispatch.cancelled_turn = None; stream.dispatch.cancelled_turn = None;
} }
stream.dispatch.settled.notify_waiters(); stream.dispatch.settled.notify_waiters();
if stream.projection.active_turn() != Some(turn) { if stream.projection.active_turn() == Some(turn) {
return; cancel_pending_approvals(&mut stream);
} if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
cancel_pending_approvals(&mut stream); let projected = persist_turn_update(&mut stream, projected);
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) { stream.broadcast(projected);
let projected = persist_turn_update(&mut stream, projected); }
let _ = stream.projected_live.send(projected);
} }
// Drain the prompt queue: concatenate all queued prompts into a single
// prompt and dispatch as the next turn. This must run even when the
// projection already ended the turn (e.g. cancel settled via the event
// stream before this function ran).
let queued_text = if stream.prompt_queue.is_empty() {
None
} else {
let texts: Vec<String> = stream.prompt_queue.drain(..).collect();
Some(texts.join("\n\n"))
};
drop(stream); drop(stream);
sync_structured_activity(&self.inner, id).await; sync_structured_activity(&self.inner, id).await;
if let Some(text) = queued_text {
self.persist_prompt_queue(id).await;
// Cannot call queue_acp_prompt here (async cycle via the spawn
// inside queue_acp_prompt → finish_queued_acp_turn → here).
// Spawn an independent task instead.
let manager = self.clone();
drain_prompt_queue(manager, id, text);
}
} }
async fn fail_cancelled_acp_turn( async fn fail_cancelled_acp_turn(
@@ -983,7 +1085,7 @@ impl SessionManager {
.turn_lifecycle(TurnLifecycle::Cancel, turn) .turn_lifecycle(TurnLifecycle::Cancel, turn)
.expect("active turn remains current while stream is locked"); .expect("active turn remains current while stream is locked");
let projected = persist_turn_update(&mut stream, projected); let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected); stream.broadcast(projected);
drop(stream); drop(stream);
sync_structured_activity(&self.inner, id).await; sync_structured_activity(&self.inner, id).await;
if let Some(turn) = cancelled_queued_turn { if let Some(turn) = cancelled_queued_turn {
@@ -1032,7 +1134,7 @@ impl SessionManager {
let outcome = permission_outcome(decision, &pending.options); let outcome = permission_outcome(decision, &pending.options);
let _ = pending.response.send(outcome); let _ = pending.response.send(outcome);
if let Some(update) = stream.projection.resolve_approval(nonce) { if let Some(update) = stream.projection.resolve_approval(nonce) {
let _ = stream.projected_live.send(update); stream.broadcast(update);
} }
drop(stream); drop(stream);
sync_structured_activity(&self.inner, id).await; sync_structured_activity(&self.inner, id).await;
@@ -1340,6 +1442,7 @@ impl SessionManager {
pending_approvals: HashMap::new(), pending_approvals: HashMap::new(),
operation: Arc::new(Mutex::new(())), operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(), dispatch: AcpDispatchState::default(),
prompt_queue: VecDeque::new(),
activity_sequence: 0, activity_sequence: 0,
})) }))
} }
@@ -1404,6 +1507,8 @@ impl SessionManager {
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
view: req.view, view: req.view,
auto_approve: req.auto_approve,
queued_prompts: Vec::new(),
}; };
if let Err(e) = self.inner.sidecar.write(&session) { if let Err(e) = self.inner.sidecar.write(&session) {
@@ -1435,7 +1540,6 @@ impl SessionManager {
structured_activity_watermark: None, structured_activity_watermark: None,
acp_event_pump: None, acp_event_pump: None,
auto_approve: req.auto_approve, auto_approve: req.auto_approve,
pending_prompt: None,
}, },
); );
} }
@@ -1489,8 +1593,8 @@ 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);
// Drain any prompt submitted while Starting. // Drain any prompts queued while the adapter was Starting.
let pending_prompt = { let queued_text = {
let mut sessions = inner.sessions.lock().await; let mut sessions = inner.sessions.lock().await;
let Some(record) = sessions.get_mut(&id) else { let Some(record) = sessions.get_mut(&id) else {
// Session was killed while we were booting. // Session was killed while we were booting.
@@ -1504,17 +1608,30 @@ impl SessionManager {
record.session.acp_session_id = Some(acp.session_id().to_string()); record.session.acp_session_id = Some(acp.session_id().to_string());
record.session.activity = ActivityState::Idle; record.session.activity = ActivityState::Idle;
record.session.activity_since = Utc::now(); record.session.activity_since = Utc::now();
let queued = if let Some(ref stream) = record.acp_events {
let mut stream = stream.lock().await;
if stream.prompt_queue.is_empty() {
None
} else {
let texts: Vec<String> =
stream.prompt_queue.drain(..).collect();
Some(texts.join("\n\n"))
}
} else {
None
};
record.session.queued_prompts.clear();
let _ = inner.sidecar.write(&record.session); let _ = inner.sidecar.write(&record.session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { let _ = inner.events.send(CatalogEvent::SessionUpdated {
session: record.session.clone(), session: record.session.clone(),
}); });
record.pending_prompt.take() queued
}; };
if let Some(prompt) = pending_prompt { if let Some(text) = queued_text {
let manager = SessionManager { let manager = SessionManager {
inner: inner.clone(), inner: inner.clone(),
}; };
let _ = manager.queue_acp_prompt(id, prompt).await; let _ = manager.queue_acp_prompt(id, text).await;
} }
} }
Err(e) => { Err(e) => {
@@ -1678,6 +1795,8 @@ impl SessionManager {
// Unknown on adoption without a sidecar; matches every // Unknown on adoption without a sidecar; matches every
// other field's forward-compat default. // other field's forward-compat default.
view: maestro_protocol::SessionView::Terminal, view: maestro_protocol::SessionView::Terminal,
auto_approve: false,
queued_prompts: Vec::new(),
}, },
}; };
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
@@ -1715,8 +1834,7 @@ impl SessionManager {
acp_events: None, acp_events: None,
structured_activity_watermark: None, structured_activity_watermark: None,
acp_event_pump: None, acp_event_pump: None,
auto_approve: false, auto_approve: session.auto_approve,
pending_prompt: None,
}, },
); );
let _ = self let _ = self
@@ -1844,6 +1962,7 @@ impl SessionManager {
pending_approvals: HashMap::new(), pending_approvals: 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(),
activity_sequence: 0, activity_sequence: 0,
}))) })))
} }
@@ -1891,21 +2010,35 @@ impl SessionManager {
focused_since: None, focused_since: None,
awaiting_suppressed: false, awaiting_suppressed: false,
acp, acp,
acp_events, acp_events: acp_events.clone(),
structured_activity_watermark: None, structured_activity_watermark: None,
acp_event_pump: None, acp_event_pump: None,
// Recovery cannot know the original preset's flag; the auto_approve: session.auto_approve,
// operator re-enables it by restarting with the preset.
auto_approve: false,
pending_prompt: None,
}, },
); );
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, false); start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
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);
} }
// Drain any prompts that were queued before the daemon restarted.
let queued_text = {
let stream = acp_events
.as_ref()
.expect("recovery with acp implies acp_events");
let mut stream = stream.lock().await;
if stream.prompt_queue.is_empty() {
None
} else {
let texts: Vec<String> = stream.prompt_queue.drain(..).collect();
Some(texts.join("\n\n"))
}
};
if let Some(text) = queued_text {
self.persist_prompt_queue(id).await;
let _ = self.queue_acp_prompt(id, text).await;
}
} }
let _ = self let _ = self
.inner .inner
@@ -2264,7 +2397,7 @@ fn start_acp_event_pump(
}; };
let projected = apply_persisted_event(&mut events.projection, &logged); let projected = apply_persisted_event(&mut events.projection, &logged);
let _ = events.live.send(logged); let _ = events.live.send(logged);
let _ = events.projected_live.send(projected); events.broadcast(projected);
None None
} }
Err(error) => Some(error), Err(error) => Some(error),
@@ -2434,7 +2567,7 @@ fn start_acp_permission_pump(
let outcome = record_permission_request(&mut events, request, response); let outcome = record_permission_request(&mut events, request, response);
let fatal = matches!(outcome, RecordOutcome::Fatal); let fatal = matches!(outcome, RecordOutcome::Fatal);
if let RecordOutcome::Live(update) = outcome { if let RecordOutcome::Live(update) = outcome {
let _ = events.projected_live.send(*update); events.broadcast(*update);
} }
drop(events); drop(events);
if fatal { if fatal {
@@ -2527,7 +2660,7 @@ fn cancel_pending_approvals(events: &mut AcpEventStream) {
.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled); .send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
} }
if let Some(update) = events.projection.clear_approval() { if let Some(update) = events.projection.clear_approval() {
let _ = events.projected_live.send(update); events.broadcast(update);
} }
} }
@@ -2877,6 +3010,18 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
/// Terminal sessions that die naturally do not survive a daemon restart: /// Terminal sessions that die naturally do not survive a daemon restart:
/// [`SessionManager::recover`] adopts only what tmux still holds. Structured /// [`SessionManager::recover`] adopts only what tmux still holds. Structured
/// sessions stopped for daemon shutdown retain their sidecars for ACP recovery. /// sessions stopped for daemon shutdown retain their sidecars for ACP recovery.
/// Spawn an independent task to dispatch the next prompt from the drained
/// queue. Free function with an explicit `Box::pin` future to break the
/// compiler's async-cycle check: `queue_acp_prompt` → spawn →
/// `finish_queued_acp_turn` → here → `queue_acp_prompt`.
fn drain_prompt_queue(manager: SessionManager, id: Uuid, text: String) {
let fut: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> =
Box::pin(async move {
let _ = manager.queue_acp_prompt(id, text).await;
});
tokio::spawn(fut);
}
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) { async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
if inner.shutting_down.load(Ordering::Acquire) { if inner.shutting_down.load(Ordering::Acquire) {
return; return;
@@ -2930,7 +3075,9 @@ async fn mark_session_dead_with_worktree_settlement(
.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 stream = stream.lock().await;
cancel_pending_approvals(&mut stream);
stream.prompt_queue.clear();
} }
tracing::info!(%id, "session process gone — marking dead"); tracing::info!(%id, "session process gone — marking dead");
@@ -2957,6 +3104,7 @@ async fn mark_session_dead_with_worktree_settlement(
return false; return false;
}; };
record.settled = true; record.settled = true;
record.session.queued_prompts.clear();
record.session.clone() record.session.clone()
}; };
@@ -3023,12 +3171,7 @@ async fn settle_worktree_on_exit(
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't /// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the /// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
/// liveness reaper (tmux already lost the session). /// liveness reaper (tmux already lost the session).
async fn cleanup_record( async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kill_tmux: bool) {
inner: &Arc<Inner>,
id: Uuid,
record: SessionRecord,
kill_tmux: bool,
) {
if kill_tmux { if kill_tmux {
let tmux = inner.tmux.clone(); let tmux = inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await; let _ = tmux_blocking(move || tmux.kill_session(id)).await;
@@ -3125,6 +3268,7 @@ mod tests {
pending_approvals: std::collections::HashMap::new(), pending_approvals: 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(),
activity_sequence: 0, activity_sequence: 0,
} }
} }
+6 -6
View File
@@ -262,7 +262,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
.queue_acp_prompt(session.id, "fail".into()) .queue_acp_prompt(session.id, "fail".into())
.await .await
.unwrap(), .unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued maestro_daemon::session::QueueAcpPrompt::Dispatched
)); ));
assert_eq!( assert_eq!(
fx.manager fx.manager
@@ -289,7 +289,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
.queue_acp_prompt(session.id, "succeed".into()) .queue_acp_prompt(session.id, "succeed".into())
.await .await
.unwrap(), .unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued maestro_daemon::session::QueueAcpPrompt::Dispatched
)); ));
assert_eq!( assert_eq!(
fx.manager fx.manager
@@ -429,7 +429,7 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
.queue_acp_prompt(session.id, "show my prompt".into()) .queue_acp_prompt(session.id, "show my prompt".into())
.await .await
.expect("queue prompt"), .expect("queue prompt"),
maestro_daemon::session::QueueAcpPrompt::Queued maestro_daemon::session::QueueAcpPrompt::Dispatched
)); ));
let (snapshot, _, _) = fx let (snapshot, _, _) = fx
@@ -519,7 +519,7 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
.queue_acp_prompt(session.id, "first".into()) .queue_acp_prompt(session.id, "first".into())
.await .await
.unwrap(), .unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued maestro_daemon::session::QueueAcpPrompt::Dispatched
)); ));
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap()); assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
let second = fx let second = fx
@@ -558,7 +558,7 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
.queue_acp_prompt(session.id, "first".into()) .queue_acp_prompt(session.id, "first".into())
.await .await
.unwrap(), .unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued maestro_daemon::session::QueueAcpPrompt::Dispatched
)); ));
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap()); assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
assert!(matches!( assert!(matches!(
@@ -566,7 +566,7 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
.queue_acp_prompt(session.id, "second".into()) .queue_acp_prompt(session.id, "second".into())
.await .await
.unwrap(), .unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued maestro_daemon::session::QueueAcpPrompt::Dispatched
)); ));
let nonce = tokio::time::timeout(Duration::from_secs(2), async { let nonce = tokio::time::timeout(Duration::from_secs(2), async {
+6 -15
View File
@@ -293,7 +293,7 @@ async fn structured_attachments_preserve_bytes_and_reject_paths() {
} }
#[tokio::test] #[tokio::test]
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() { async fn structured_prompt_queues_during_active_turn_and_cancel_clears_it() {
let h = spawn("naps-laptop").await; let h = spawn("naps-laptop").await;
let fake = FakeAcp::new(); let fake = FakeAcp::new();
let mut env = fake.env(); let mut env = fake.env();
@@ -310,6 +310,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
.await .await
.unwrap(); .unwrap();
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT); assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
// A second prompt while a turn is active is accepted into the queue.
let second = client let second = client
.post(url( .post(url(
h.addr, h.addr,
@@ -319,9 +320,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
.send() .send()
.await .await
.unwrap(); .unwrap();
assert_eq!(second.status(), reqwest::StatusCode::CONFLICT); assert_eq!(second.status(), reqwest::StatusCode::ACCEPTED);
let error: ApiError = second.json().await.unwrap();
assert_eq!(error.error.code, "turn_in_flight");
let cancel = client let cancel = client
.post(url( .post(url(
@@ -332,16 +331,8 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
.await .await
.unwrap(); .unwrap();
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT); assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
let next = client // After cancel + settlement, the queued "second" prompt drains. Wait for
.post(url( // both the first and the drained prompt to reach ACP.
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "next"}))
.send()
.await
.unwrap();
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
tokio::time::timeout(Duration::from_secs(2), async { tokio::time::timeout(Duration::from_secs(2), async {
loop { loop {
if fake.log().matches("recv session/prompt").count() == 2 { if fake.log().matches("recv session/prompt").count() == 2 {
@@ -351,7 +342,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
} }
}) })
.await .await
.expect("both prompt commands reach ACP"); .expect("first and drained queued prompt both reach ACP");
let log = fake.log(); let log = fake.log();
let first_prompt = log.find("recv session/prompt").unwrap(); let first_prompt = log.find("recv session/prompt").unwrap();
let cancel = log.find("recv session/cancel").unwrap(); let cancel = log.find("recv session/cancel").unwrap();
+2
View File
@@ -34,6 +34,8 @@ fn sample(id: Uuid) -> Session {
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
view: SessionView::Terminal, view: SessionView::Terminal,
auto_approve: false,
queued_prompts: Vec::new(),
} }
} }
+2
View File
@@ -744,6 +744,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
} }
} }
+16
View File
@@ -132,6 +132,16 @@ pub struct Session {
/// the two are minted by different processes with different lifetimes. /// the two are minted by different processes with different lifetimes.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub acp_session_id: Option<String>, pub acp_session_id: Option<String>,
/// When `true` the daemon auto-approves every ACP permission request for
/// this session instead of surfacing them to the client. Set at spawn time
/// from the preset or the `CreateSessionRequest`; persisted so it survives
/// a daemon restart.
#[serde(default)]
pub auto_approve: bool,
/// Prompts queued on the daemon while a turn was in flight or the adapter
/// was still booting. Persisted so the queue survives a daemon restart.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queued_prompts: Vec<String>,
} }
/// A session's process shape (#133): a plain terminal, or a structured agent /// A session's process shape (#133): a plain terminal, or a structured agent
@@ -872,6 +882,10 @@ pub struct ReducedState {
pub mode: Option<String>, pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub config_options: Option<Value>, pub config_options: Option<Value>,
/// Prompts queued on the daemon while a turn is in flight. Drained as
/// a single concatenated prompt when the current turn completes.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queued_prompts: Vec<String>,
} }
/// Server messages sent over a structured-session WebSocket. /// Server messages sent over a structured-session WebSocket.
@@ -1054,6 +1068,8 @@ mod tests {
last_focused_at: None, last_focused_at: None,
view: SessionView::Terminal, view: SessionView::Terminal,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
} }
} }
+2
View File
@@ -50,6 +50,8 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
} }
} }
+12
View File
@@ -2216,6 +2216,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}) })
.collect(), .collect(),
}); });
@@ -2399,6 +2401,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}], }],
}); });
app.dispatch(Action::SidebarNext); app.dispatch(Action::SidebarNext);
@@ -2428,6 +2432,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
} }
} }
@@ -3590,6 +3596,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: focused, last_focused_at: focused,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}; };
let older = Utc::now() - chrono::Duration::seconds(60); let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -3870,6 +3878,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}], }],
}); });
app.dispatch(Action::KillHighlighted); app.dispatch(Action::KillHighlighted);
@@ -3907,6 +3917,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}], }],
}); });
} }
+2
View File
@@ -225,6 +225,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
} }
} }
+2
View File
@@ -811,6 +811,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
} }
} }
+4
View File
@@ -502,6 +502,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
} }
} }
@@ -1272,6 +1274,8 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}; };
let sb = Sidebar { let sb = Sidebar {
sessions: vec![s], sessions: vec![s],
+17 -12
View File
@@ -353,21 +353,26 @@ thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn." allowed to claim the daemon's idea of "the active turn."
- Web clients may collect a visible FIFO of operator messages while a turn is - Prompts are accepted at any time: if the session is idle the daemon dispatches
active. They submit each queued message only after the projected turn becomes immediately (`204`); if a turn is active the prompt is appended to a
idle; the queue is client-local and unsent messages do not survive a reload. server-side FIFO (`202 Accepted`, capped at 20 entries, `429` when full).
When the active turn completes, all queued prompts are concatenated with
`\n\n` and dispatched as the next turn. The queue is persisted in the session
sidecar and survives daemon restarts — clients never queue locally.
`ReducedState.queued_prompts` contains the current queue so every connected
client can render the pending messages.
- 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. While a operator's input. If that append fails, the prompt is not dispatched.
turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing in the daemon — one active turn per session, never a hidden
server-side queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`, - Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the cancelled and ends the turn's client-visible lifecycle immediately. The prompt queue
turn was still *dispatched* to ACP (waiting on its response), the daemon is preserved: after the cancelled turn settles, the queued prompts drain as
cannot simply forget it: ACP has not confirmed the cancel yet, and a new the next turn. If the session dies instead (settlement timeout), the queue
prompt claiming "the active turn" while the old one is still outstanding is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
would make the next permission callback ambiguous between the two. its response), the daemon cannot simply forget it: ACP has not confirmed the
cancel yet, and a new prompt claiming "the active turn" while the old one is
still outstanding would make the next permission callback ambiguous between
the two.
- So a cancelled-but-still-dispatched turn must **settle within - So a cancelled-but-still-dispatched turn must **settle within
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it `CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
resolves) or that grace period runs out. If it settles in time, the next resolves) or that grace period runs out. If it settles in time, the next
@@ -308,7 +308,7 @@ describe("prompt and cancel", () => {
); );
}); });
it("queues a second message while a turn is in flight, and sends it when the turn ends", async () => { it("always POSTs a prompt even while a turn is active (server queues it)", async () => {
const { socket } = mount(); const { socket } = mount();
snapshot(socket, [], busy); snapshot(socket, [], busy);
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, { fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
@@ -316,31 +316,17 @@ describe("prompt and cancel", () => {
}); });
fireEvent.submit(document.querySelector("form.structured-composer")!); fireEvent.submit(document.querySelector("form.structured-composer")!);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
expect(document.querySelector(".st-row-queued")?.textContent).toBe("next up");
socket.emit({ type: "reduced_state", state: idle });
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up")); await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
}); });
it("concatenates multiple queued messages into a single prompt", async () => { it("renders server-side queued prompts from state", () => {
const { socket } = mount(); const { socket } = mount();
snapshot(socket, [], busy); snapshot(socket, [], { ...busy, queued_prompts: ["first", "second"] });
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
const form = document.querySelector("form.structured-composer")!;
fireEvent.change(input, { target: { value: "first" } });
fireEvent.submit(form);
fireEvent.change(input, { target: { value: "second" } });
fireEvent.submit(form);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const queued = document.querySelectorAll(".st-row-queued"); const queued = document.querySelectorAll(".st-row-queued");
expect(queued).toHaveLength(2); expect(queued).toHaveLength(2);
expect(queued[0].textContent).toBe("first");
socket.emit({ type: "reduced_state", state: idle }); expect(queued[1].textContent).toBe("second");
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "first\n\nsecond"));
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
}); });
it("hands a failed send's draft back to the operator", async () => { it("hands a failed send's draft back to the operator", async () => {
@@ -48,11 +48,6 @@ import "./structured.css";
* count as following. */ * count as following. */
const FOLLOW_BOTTOM_THRESHOLD_PX = 64; const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
interface QueuedPrompt {
text: string;
attachments: File[];
}
export interface StructuredPaneProps { export interface StructuredPaneProps {
sessionId: Uuid; sessionId: Uuid;
/** Grabs the composer once mounted. A sidebar preview passes `false`. */ /** Grabs the composer once mounted. A sidebar preview passes `false`. */
@@ -85,7 +80,6 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<string>>(new Set());
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<File[]>([]); const [attachments, setAttachments] = useState<File[]>([]);
const [queue, setQueue] = useState<QueuedPrompt[]>([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [configBusy, setConfigBusy] = useState(false); const [configBusy, setConfigBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -100,7 +94,6 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
const submission = useRef(0); const submission = useRef(0);
const accepted = useRef<{ generation: number; socketGeneration: number } | null>(null); const accepted = useRef<{ generation: number; socketGeneration: number } | null>(null);
const optimisticStart = useRef<number | null>(null); const optimisticStart = useRef<number | null>(null);
const promptPending = useRef(false);
const [awaitingBegin, setAwaitingBegin] = useState<number | null>(null); const [awaitingBegin, setAwaitingBegin] = useState<number | null>(null);
useImperativeHandle( useImperativeHandle(
@@ -166,7 +159,7 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
const el = scrollRef.current; const el = scrollRef.current;
if (!el || !followingBottom.current) return; if (!el || !followingBottom.current) return;
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight); el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
}, [items, active, queue]); }, [items, active, state.queued_prompts]);
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
@@ -207,7 +200,7 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
setAwaitingBegin(generation); setAwaitingBegin(generation);
optimisticStart.current = Date.now(); optimisticStart.current = Date.now();
accepted.current = null; accepted.current = null;
promptPending.current = true;
setUploading(files.length > 0); setUploading(files.length > 0);
setDraft(""); setDraft("");
setError(null); setError(null);
@@ -224,7 +217,6 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
void send().then( void send().then(
() => { () => {
promptPending.current = false;
if (generation !== submission.current) return; if (generation !== submission.current) return;
setUploading(false); setUploading(false);
setAttachments((current) => (current === files ? [] : current)); setAttachments((current) => (current === files ? [] : current));
@@ -235,7 +227,6 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
if (socketGeneration > submissionSocket) session.reconnect(); if (socketGeneration > submissionSocket) session.reconnect();
}, },
(e: unknown) => { (e: unknown) => {
promptPending.current = false;
if (generation !== submission.current) return; if (generation !== submission.current) return;
setAwaitingBegin((current) => (current === generation ? null : current)); setAwaitingBegin((current) => (current === generation ? null : current));
optimisticStart.current = null; optimisticStart.current = null;
@@ -251,26 +242,10 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
[sessionId, socketGeneration, session], [sessionId, socketGeneration, session],
); );
// Queued messages are concatenated into a single prompt so the agent sees // The server accepts prompts at any time: if a turn is active the prompt is
// one user turn instead of N back-to-back turns. The daemon rejects a // queued server-side and drained when the turn completes. The client always
// second prompt while a turn is in flight (`409 turn_in_flight`), so the // POSTs immediately — no local queue.
// queue is client-local (docs/STRUCTURED-VIEW.md).
useEffect(() => {
if (active || promptPending.current || queue.length === 0) return;
const text = queue.map((q) => q.text).join("\n\n");
const files = queue.flatMap((q) => q.attachments);
setQueue([]);
dispatchPrompt(text, files);
}, [active, queue, dispatchPrompt]);
const submit = (text: string, files: File[]) => { const submit = (text: string, files: File[]) => {
if (active || queue.length > 0) {
setQueue((current) => [...current, { text, attachments: files }]);
setAttachments([]);
setDraft("");
setError(null);
return;
}
dispatchPrompt(text, files); dispatchPrompt(text, files);
}; };
@@ -365,10 +340,10 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
</ToolGroup> </ToolGroup>
), ),
)} )}
{queue.map((queued, index) => ( {(state.queued_prompts ?? []).map((text, index) => (
<div key={`queued-${index}`} className="st-row st-row-queued"> <div key={`queued-${index}`} className="st-row st-row-queued">
<div className="st-body"> <div className="st-body">
<div className="st-user-text">{queued.text}</div> <div className="st-user-text">{text}</div>
</div> </div>
</div> </div>
))} ))}
+5 -1
View File
@@ -726,7 +726,11 @@
} }
.st-tool-group-title { .st-tool-group-title {
flex: 0 0 auto; flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500; font-weight: 500;
} }
+1
View File
@@ -441,6 +441,7 @@ export interface ReducedState {
model?: string | null; model?: string | null;
mode?: string | null; mode?: string | null;
config_options?: { configOptions: AcpSessionConfigOption[] } | null; config_options?: { configOptions: AcpSessionConfigOption[] } | null;
queued_prompts?: string[];
} }
/** Server messages on `WS /api/structured/:id`. */ /** Server messages on `WS /api/structured/:id`. */