fix(custom-cli): busy/progress/tools/chatbox bloqués après le premier appel — #245 (QA verte)

Backend : StructuredHumanTurnGuard pour partager la comptabilité busy/FIFO entre chat direct humain (agent_send, web chat.send) et asks inter-agents structurés, avec liveness mark_alive et retrait idempotent du tour. Web-server : tap d'événements sur chat.send pour le scrollback temps réel. Frontend : scoping des clés de progression par tour pour éviter la déduplication/ghosting des progress et outils entre appels.
This commit is contained in:
2026-08-23 13:34:56 +02:00
parent 55637abd6d
commit f1d85dfc5a
5 changed files with 728 additions and 58 deletions

View File

@ -1870,13 +1870,50 @@ async fn ws_send_chat(
code: "NOT_FOUND".to_owned(),
message: "structured chat session not found".to_owned(),
})?;
let stream = session.send(&prompt).await.map_err(|err| ErrorDto {
let session_meta = state.app.structured_sessions.meta_for_session(&sid);
let turn_guard = session_meta.as_ref().map(|(project_id, agent_id, _, _)| {
backend::StructuredHumanTurnGuard::start(
Arc::clone(&state.app.turn_watch_input),
Arc::clone(&state.app.input_mailbox),
*project_id,
*agent_id,
&prompt,
)
});
let (tap_tx, tap_rx) = std::sync::mpsc::channel();
let tap_out = tx.clone();
let tap_turn_guard = turn_guard.as_ref().map(|guard| guard.liveness_handle());
let tap_pump = std::thread::spawn(move || {
for event in tap_rx {
if !matches!(event, ReplyEvent::Final { .. }) {
if let Some(guard) = &tap_turn_guard {
guard.mark_alive();
}
}
let chunk = reply_event_chunk(event);
if tap_out
.blocking_send(ServerFrame::chat_output(None, sid, chunk))
.is_err()
{
break;
}
}
});
let stream_result = session.send_with_tap(&prompt, tap_tx).await;
let _ = tap_pump.join();
let stream = stream_result.map_err(|err| ErrorDto {
code: "CHAT_SEND_FAILED".to_owned(),
message: err.to_string(),
})?;
let mut sent_ack = false;
for event in stream {
if !matches!(event, ReplyEvent::Final { .. }) {
if let Some(guard) = &turn_guard {
guard.mark_alive();
}
}
let chunk = reply_event_chunk(event);
if !sent_ack {
tx.send(ServerFrame::chat_output(Some(&frame.id), sid, chunk))
@ -1909,6 +1946,10 @@ async fn ws_send_chat(
})?;
}
if let Some(guard) = &turn_guard {
guard.finish();
}
Ok(())
}
@ -6820,6 +6861,7 @@ mod tests {
id: SessionId,
conversation_id: Option<String>,
events: Vec<ReplyEvent>,
tap_events: Vec<ReplyEvent>,
prompts: Mutex<Vec<String>>,
}
@ -6829,6 +6871,21 @@ mod tests {
id,
conversation_id: Some("engine-chat-session".to_owned()),
events,
tap_events: Vec::new(),
prompts: Mutex::new(Vec::new()),
}
}
fn with_tap_events(
id: SessionId,
events: Vec<ReplyEvent>,
tap_events: Vec<ReplyEvent>,
) -> Self {
Self {
id,
conversation_id: Some("engine-chat-session".to_owned()),
events,
tap_events,
prompts: Mutex::new(Vec::new()),
}
}
@ -6852,6 +6909,92 @@ mod tests {
Ok(Box::new(self.events.clone().into_iter()))
}
async fn send_with_tap(
&self,
prompt: &str,
tap: std::sync::mpsc::Sender<ReplyEvent>,
) -> Result<ReplyStream, AgentSessionError> {
for event in self.tap_events.clone() {
let _ = tap.send(event);
}
self.send(prompt).await
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
struct GatedChatSession {
id: SessionId,
prompts: Mutex<Vec<String>>,
started: Mutex<Option<oneshot::Sender<()>>>,
release: Mutex<Option<oneshot::Receiver<()>>>,
}
impl GatedChatSession {
fn new(
id: SessionId,
started: oneshot::Sender<()>,
release: oneshot::Receiver<()>,
) -> Self {
Self {
id,
prompts: Mutex::new(Vec::new()),
started: Mutex::new(Some(started)),
release: Mutex::new(Some(release)),
}
}
}
#[async_trait]
impl AgentSession for GatedChatSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
Some("engine-gated-chat-session".to_owned())
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.send_with_tap(prompt, std::sync::mpsc::channel().0)
.await
}
async fn send_with_tap(
&self,
prompt: &str,
_tap: std::sync::mpsc::Sender<ReplyEvent>,
) -> Result<ReplyStream, AgentSessionError> {
self.prompts
.lock()
.expect("prompt trace mutex poisoned")
.push(prompt.to_owned());
if let Some(started) = self
.started
.lock()
.expect("started gate mutex poisoned")
.take()
{
let _ = started.send(());
}
let release = self
.release
.lock()
.expect("release gate mutex poisoned")
.take();
if let Some(release) = release {
let _ = release.await;
}
Ok(Box::new(
vec![ReplyEvent::Final {
content: "released".to_owned(),
}]
.into_iter(),
))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
@ -7533,6 +7676,130 @@ mod tests {
);
}
#[tokio::test]
async fn websocket_chat_send_marks_direct_structured_turn_busy_until_final() {
let state = state();
let session_id = SessionId::new_random();
let agent_id = AgentId::from_uuid(Uuid::new_v4());
let node_id = NodeId::from_uuid(Uuid::new_v4());
let (started_tx, started_rx) = oneshot::channel();
let (release_tx, release_rx) = oneshot::channel();
let session = Arc::new(GatedChatSession::new(session_id, started_tx, release_rx));
state.app.structured_sessions.insert(
Arc::clone(&session) as Arc<dyn AgentSession>,
agent_id,
node_id,
);
let runtime_key = domain::RuntimeAgentKey::new(ProjectId::from_uuid(Uuid::nil()), agent_id);
let (tx, mut rx) = mpsc::channel(16);
let owned = Arc::new(Mutex::new(Vec::new()));
let frame = ws_frame(
"chat-send-busy",
"chat.send",
json!({ "sessionId": session_id.to_string(), "prompt": "Work" }),
);
let send_task = tokio::spawn({
let state = Arc::clone(&state);
let tx = tx.clone();
let owned = Arc::clone(&owned);
async move {
handle_client_frame(frame, &state, &tx, &owned).await;
}
});
started_rx.await.expect("gated session started");
assert!(
state.app.turn_watch_input.busy_state(runtime_key).is_busy(),
"direct structured human turn should be busy while send is in flight"
);
release_tx.send(()).expect("release gated session");
let final_chunk = recv_server_frame(&mut rx).await;
send_task.await.expect("chat send task finished");
assert_eq!(final_chunk.kind, "chat.output");
assert_eq!(final_chunk.reply_to.as_deref(), Some("chat-send-busy"));
assert_eq!(
final_chunk.payload["chunk"],
json!({ "kind": "final", "content": "released" })
);
assert!(
!state.app.turn_watch_input.busy_state(runtime_key).is_busy(),
"direct structured human turn should be idle after final"
);
}
#[tokio::test]
async fn websocket_chat_send_relays_tap_progress_live() {
let state = state();
let session_id = SessionId::new_random();
let agent_id = AgentId::from_uuid(Uuid::new_v4());
let node_id = NodeId::from_uuid(Uuid::new_v4());
let session = Arc::new(FakeChatSession::with_tap_events(
session_id,
vec![ReplyEvent::Final {
content: "done".to_owned(),
}],
vec![ReplyEvent::Progress {
progress: ReplyProgress::new(
ReplyProgressSource::IdeaLocal,
ReplyProgressKind::Mcp,
ReplyProgressStage::Started,
"idea_ask_agent",
)
.with_tool_name("idea_ask_agent"),
}],
));
state.app.structured_sessions.insert(
Arc::clone(&session) as Arc<dyn AgentSession>,
agent_id,
node_id,
);
let (tx, mut rx) = mpsc::channel(16);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"chat-send-progress",
"chat.send",
json!({ "sessionId": session_id.to_string(), "prompt": "Run tool" }),
),
&state,
&tx,
&owned,
)
.await;
let first = recv_server_frame(&mut rx).await;
let second = recv_server_frame(&mut rx).await;
let frames = [first, second];
let progress = frames
.iter()
.find(|frame| frame.payload["chunk"]["kind"] == "progress")
.expect("tap progress frame was relayed");
let final_chunk = frames
.iter()
.find(|frame| frame.payload["chunk"]["kind"] == "final")
.expect("final frame was relayed");
assert_eq!(progress.kind, "chat.output");
assert!(progress.reply_to.is_none());
assert_eq!(progress.payload["sessionId"], session_id.to_string());
assert_eq!(progress.payload["chunk"]["progress"]["source"], "ideaLocal");
assert_eq!(progress.payload["chunk"]["progress"]["kind"], "mcp");
assert_eq!(progress.payload["chunk"]["progress"]["stage"], "started");
assert_eq!(
progress.payload["chunk"]["progress"]["toolName"],
"idea_ask_agent"
);
assert_eq!(final_chunk.reply_to.as_deref(), Some("chat-send-progress"));
assert_eq!(
final_chunk.payload["chunk"],
json!({ "kind": "final", "content": "done" })
);
}
#[tokio::test]
async fn websocket_chat_detach_unregisters_sink_without_killing_session() {
let state = state();