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

@ -2564,6 +2564,16 @@ pub async fn agent_send(
.iter() .iter()
.map(|attachment| attachment.filename.clone()) .map(|attachment| attachment.filename.clone())
.collect(); .collect();
let session_meta = state.structured_sessions.meta_for_session(&sid);
let turn_guard = session_meta.as_ref().map(|(project_id, agent_id, _, _)| {
backend::StructuredHumanTurnGuard::start(
std::sync::Arc::clone(&state.turn_watch_input),
std::sync::Arc::clone(&state.input_mailbox),
*project_id,
*agent_id,
&prompt_for_model,
)
});
// Retain the human submit in the same live scrollback as model chunks so // Retain the human submit in the same live scrollback as model chunks so
// `reattach_agent_chat` can repaint the whole in-flight conversation. // `reattach_agent_chat` can repaint the whole in-flight conversation.
@ -2583,9 +2593,15 @@ pub async fn agent_send(
let (tap_tx, tap_rx) = std::sync::mpsc::channel(); let (tap_tx, tap_rx) = std::sync::mpsc::channel();
let tap_bridge = std::sync::Arc::clone(&state.chat_bridge); let tap_bridge = std::sync::Arc::clone(&state.chat_bridge);
let tap_sid = sid; let tap_sid = sid;
let tap_turn_guard = turn_guard.as_ref().map(|guard| guard.liveness_handle());
let tap_pump = std::thread::spawn(move || { let tap_pump = std::thread::spawn(move || {
let mut settler = crate::chat::ProgressSettler::default(); let mut settler = crate::chat::ProgressSettler::default();
for event in tap_rx { for event in tap_rx {
if !matches!(event, domain::ports::ReplyEvent::Final { .. }) {
if let Some(guard) = &tap_turn_guard {
guard.mark_alive();
}
}
let Some(chunk) = crate::chat::chunk_from_event(event) else { let Some(chunk) = crate::chat::chunk_from_event(event) else {
continue; continue;
}; };
@ -2617,11 +2633,16 @@ pub async fn agent_send(
// event then feeds the service (détecter→planifier). Without a conversation id the // event then feeds the service (détecter→planifier). Without a conversation id the
// service must not pretend an automatic resume is armed. // service must not pretend an automatic resume is armed.
let service = std::sync::Arc::clone(&state.session_limit_service); let service = std::sync::Arc::clone(&state.session_limit_service);
let meta = state.structured_sessions.meta_for_session(&sid); let meta = session_meta;
std::thread::spawn(move || { std::thread::spawn(move || {
let mut terminal_visible = false; let mut terminal_visible = false;
let mut settler = crate::chat::ProgressSettler::default(); let mut settler = crate::chat::ProgressSettler::default();
for event in stream { for event in stream {
if !matches!(event, domain::ports::ReplyEvent::Final { .. }) {
if let Some(guard) = &turn_guard {
guard.mark_alive();
}
}
// Non-terminal, sans contenu chat : un signal de limite alimente le service // Non-terminal, sans contenu chat : un signal de limite alimente le service
// (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on // (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
// continue à drainer comme pour un battement. // continue à drainer comme pour un battement.
@ -2663,6 +2684,9 @@ pub async fn agent_send(
let chunk = crate::chat::unfinished_turn_chunk(); let chunk = crate::chat::unfinished_turn_chunk();
let _ = bridge.send_output_settled(&sid, &mut settler, chunk); let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
} }
if let Some(guard) = &turn_guard {
guard.finish();
}
bridge.detach_if(&sid, gen); bridge.detach_if(&sid, gen);
}); });

View File

@ -836,6 +836,87 @@ struct AppAgentResumer {
contexts: ResumeContexts, contexts: ResumeContexts,
} }
/// Guard for a direct human turn sent to a structured agent session.
///
/// Direct chat adapters (`agent_send`, web `chat.send`) still drive the provider
/// through `AgentSession::send`, but they must share the same busy/FIFO accounting
/// as structured inter-agent asks. This guard starts a silent human turn and
/// retires it on terminal outcome or drop.
pub struct StructuredHumanTurnGuard {
input: Arc<dyn domain::input::InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
agent: domain::RuntimeAgentKey,
ticket_id: domain::mailbox::TicketId,
armed: Arc<std::sync::atomic::AtomicBool>,
finish_on_drop: bool,
}
impl StructuredHumanTurnGuard {
/// Starts the mediated busy turn for a direct human structured chat message.
#[must_use]
pub fn start(
input: Arc<dyn domain::input::InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
project_id: ProjectId,
agent_id: AgentId,
prompt: &str,
) -> Self {
let agent = domain::RuntimeAgentKey::new(project_id, agent_id);
let ticket_id = domain::mailbox::TicketId::new_random();
let owns_turn = if input.busy_state(agent).is_busy() {
false
} else {
let ticket = domain::mailbox::Ticket::new(ticket_id, "user", prompt);
let _pending = input.enqueue_silent(agent, ticket);
input.busy_state(agent).ticket() == Some(ticket_id)
};
Self {
input,
mailbox,
agent,
ticket_id,
armed: Arc::new(std::sync::atomic::AtomicBool::new(owns_turn)),
finish_on_drop: true,
}
}
/// Returns a non-owning handle that can keep the turn alive from relay code.
#[must_use]
pub fn liveness_handle(&self) -> Self {
Self {
input: Arc::clone(&self.input),
mailbox: Arc::clone(&self.mailbox),
agent: self.agent,
ticket_id: self.ticket_id,
armed: Arc::clone(&self.armed),
finish_on_drop: false,
}
}
/// Records proof of liveness for the running turn.
pub fn mark_alive(&self) {
if self.armed.load(std::sync::atomic::Ordering::Acquire) {
self.input.mark_alive(self.agent);
}
}
/// Retires the FIFO head and marks the agent idle. Idempotent across clones.
pub fn finish(&self) {
if self.armed.swap(false, std::sync::atomic::Ordering::AcqRel) {
self.mailbox.cancel_head(self.agent, self.ticket_id);
self.input.mark_idle(self.agent);
}
}
}
impl Drop for StructuredHumanTurnGuard {
fn drop(&mut self) {
if self.finish_on_drop {
self.finish();
}
}
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl AgentResumer for AppAgentResumer { impl AgentResumer for AppAgentResumer {
async fn resume( async fn resume(
@ -1384,6 +1465,9 @@ pub struct BackendCore {
/// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du /// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du
/// [`turn_watcher`](Self::turn_watcher) à l'armement. /// [`turn_watcher`](Self::turn_watcher) à l'armement.
pub turn_watch_input: Arc<dyn domain::input::InputMediator>, pub turn_watch_input: Arc<dyn domain::input::InputMediator>,
/// Mailbox paired with [`Self::turn_watch_input`], used by direct structured
/// human turns to retire the synthetic busy/FIFO ticket on terminal outcome.
pub input_mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
/// Handles des watches de fin-de-tour vivants, par clé runtime. (Re)lancer un agent /// Handles des watches de fin-de-tour vivants, par clé runtime. (Re)lancer un agent
/// dans un projet **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; /// dans un projet **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ;
/// fermer/arrêter l'agent le retire. `Mutex` car launch/stop y accèdent concurremment. /// fermer/arrêter l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
@ -3259,6 +3343,7 @@ impl BackendCore {
resume_contexts, resume_contexts,
turn_watcher, turn_watcher,
turn_watch_input: Arc::clone(&input_mediator), turn_watch_input: Arc::clone(&input_mediator),
input_mailbox: Arc::clone(&mailbox),
turn_watch_handles: Mutex::new(HashMap::new()), turn_watch_handles: Mutex::new(HashMap::new()),
fs_port: Arc::clone(&fs_port), fs_port: Arc::clone(&fs_port),
home_dir, home_dir,

View File

@ -1870,13 +1870,50 @@ async fn ws_send_chat(
code: "NOT_FOUND".to_owned(), code: "NOT_FOUND".to_owned(),
message: "structured chat session 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(), code: "CHAT_SEND_FAILED".to_owned(),
message: err.to_string(), message: err.to_string(),
})?; })?;
let mut sent_ack = false; let mut sent_ack = false;
for event in stream { for event in stream {
if !matches!(event, ReplyEvent::Final { .. }) {
if let Some(guard) = &turn_guard {
guard.mark_alive();
}
}
let chunk = reply_event_chunk(event); let chunk = reply_event_chunk(event);
if !sent_ack { if !sent_ack {
tx.send(ServerFrame::chat_output(Some(&frame.id), sid, chunk)) 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(()) Ok(())
} }
@ -6820,6 +6861,7 @@ mod tests {
id: SessionId, id: SessionId,
conversation_id: Option<String>, conversation_id: Option<String>,
events: Vec<ReplyEvent>, events: Vec<ReplyEvent>,
tap_events: Vec<ReplyEvent>,
prompts: Mutex<Vec<String>>, prompts: Mutex<Vec<String>>,
} }
@ -6829,6 +6871,21 @@ mod tests {
id, id,
conversation_id: Some("engine-chat-session".to_owned()), conversation_id: Some("engine-chat-session".to_owned()),
events, 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()), prompts: Mutex::new(Vec::new()),
} }
} }
@ -6852,6 +6909,92 @@ mod tests {
Ok(Box::new(self.events.clone().into_iter())) 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> { async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(()) 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] #[tokio::test]
async fn websocket_chat_detach_unregisters_sink_without_killing_session() { async fn websocket_chat_detach_unregisters_sink_without_killing_session() {
let state = state(); let state = state();

View File

@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import type { AgentProfile, ReplyChunk } from "@/domain"; import type { AgentProfile, ReplyChunk } from "@/domain";
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import { PluginRuntimeProvider } from "@/features/plugins"; import { PluginRuntimeProvider } from "@/features/plugins";
import { import {
__resetProjectCliInteractionActivityForTests, __resetProjectCliInteractionActivityForTests,
@ -912,6 +913,158 @@ describe("CustomAgentChatView", () => {
).toBe(true); ).toBe(true);
}); });
it("keeps the composer locked from canonical work-state busy even without local stream activity", async () => {
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
workState._setProjectWorkState("project-1", {
agents: [
{
agentId: "agent-1",
name: "Main",
profileId: "structured",
busy: { state: "busy", ticket: "turn-1", sinceMs: 1 },
tickets: [],
},
],
conversations: [],
});
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
stopLiveAgent: vi.fn(async () => ({
agentId: "agent-1",
sessionId: "chat-session-1",
kind: "structured" as const,
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system,
workState,
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Main"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const composer = await screen.findByLabelText(/message CLI custom/);
await waitFor(() => expect(composer).toHaveProperty("disabled", true));
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
workState._setProjectWorkState("project-1", {
agents: [
{
agentId: "agent-1",
name: "Main",
profileId: "structured",
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false });
await waitFor(() => expect(composer).toHaveProperty("disabled", false));
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
});
it("shows the requester as waiting on the delegated target and keeps input locked", async () => {
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
workState._setProjectWorkState("project-1", {
agents: [
{
agentId: "main",
name: "Main",
profileId: "structured",
busy: { state: "idle" },
tickets: [],
},
{
agentId: "architect",
name: "Architect",
profileId: "structured",
busy: { state: "busy", ticket: "delegated-1", sinceMs: 1 },
tickets: [
{
ticketId: "delegated-1",
conversationId: "conversation-2",
position: 0,
status: "inProgress",
source: { kind: "agent", agentId: "main" },
requesterLabel: "Main",
taskPreview: "Analyse l'architecture",
taskLen: 22,
},
],
},
],
conversations: [],
});
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
stopLiveAgent: vi.fn(async () => ({
agentId: "main",
sessionId: "chat-session-1",
kind: "structured" as const,
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system,
workState,
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="main"
agentName="Main"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
expect(await screen.findByText("Main attend Architect")).toBeTruthy();
expect(screen.getByLabelText(/message CLI custom/)).toHaveProperty("disabled", true);
});
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => { it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(async () => ({ launchAgentChat: vi.fn(async () => ({
@ -1142,6 +1295,87 @@ describe("CustomAgentChatView", () => {
expect((await screen.findAllByText("done")).length).toBeGreaterThan(0); expect((await screen.findAllByText("done")).length).toBeGreaterThan(0);
}); });
it("renders repeated legacy tool progress as fresh activity on each turn", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async (_sessionId: string, prompt: string, onChunk) => {
onChunk({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_context_read",
toolName: "idea_context_read",
text: "Lecture du contexte",
},
});
onChunk({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "completed",
label: "idea_context_read",
toolName: "idea_context_read",
text: "Lecture du contexte",
},
});
onChunk({ kind: "final", content: `done ${prompt}` });
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "first" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("done first");
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "second" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("done second");
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
expect(screen.getAllByText("idea_context_read")).toHaveLength(2);
});
it("renders available pre-MCP progress before the first MCP card", async () => { it("renders available pre-MCP progress before the first MCP card", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null; let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = { const agent = {

View File

@ -73,6 +73,7 @@ type ChatTurn =
interface CachedChatView { interface CachedChatView {
turns: ChatTurn[]; turns: ChatTurn[];
progressIndex: Map<string, ProgressIndexEntry>; progressIndex: Map<string, ProgressIndexEntry>;
progressScope: number;
chunkCount: number; chunkCount: number;
sessionId?: string; sessionId?: string;
} }
@ -129,7 +130,8 @@ interface ChatErrorState {
} }
interface RequesterDelegationState { interface RequesterDelegationState {
active: boolean; currentAgentBusy: boolean;
delegationTargetName: string | null;
} }
function describe(e: unknown): string { function describe(e: unknown): string {
@ -144,6 +146,10 @@ function describeOpenFailure(e: unknown): string {
return message || "Cause technique non disponible."; return message || "Cause technique non disponible.";
} }
function workAgentDisplayName(agent: { agentId: string; name: string }): string {
return agent.name.trim() || `Agent ${agent.agentId.slice(0, 8)}`;
}
function structuredSessionLabel( function structuredSessionLabel(
adapter: AgentProfile["structuredAdapter"], adapter: AgentProfile["structuredAdapter"],
): string | null { ): string | null {
@ -310,14 +316,14 @@ function progressKey(progress: ReplyProgress): string {
].join("\u0000"); ].join("\u0000");
} }
function progressUpsertKey(progress: ReplyProgress): string { function progressUpsertKey(progress: ReplyProgress, scope: number): string {
const progressId = progress.progressId?.trim(); const progressId = progress.progressId?.trim();
if (progressId) return progressId; if (progressId) return progressId;
return `legacy:${progressDisplayKey(progress)}`; return `legacy:${scope}:${progressDisplayKey(progress)}`;
} }
function turnProgressKey(turn: Extract<ChatTurn, { role: "progress" }>): string { function turnProgressKey(turn: Extract<ChatTurn, { role: "progress" }>): string {
return turn.progressKey ?? progressUpsertKey(turn.progress); return turn.progressKey ?? progressUpsertKey(turn.progress, 0);
} }
function normalizedProgressToolName(progress: ReplyProgress): string | null { function normalizedProgressToolName(progress: ReplyProgress): string | null {
@ -352,11 +358,11 @@ function progressDisplayKey(progress: ReplyProgress): string {
return progressKey(progress); return progressKey(progress);
} }
function legacyActiveProgressKey(progress: ReplyProgress): string | null { function legacyActiveProgressKey(progress: ReplyProgress, scope: number): string | null {
const entity = progressEntityKey(progress); const entity = progressEntityKey(progress);
if (!entity) return null; if (!entity) return null;
if (progress.stage !== "started" && progress.stage !== "delta") return null; if (progress.stage !== "started" && progress.stage !== "delta") return null;
return `legacy:${entity}\u0000started`; return `legacy:${scope}:${entity}\u0000started`;
} }
function legacyCompletedDedupKey(progress: ReplyProgress): string | null { function legacyCompletedDedupKey(progress: ReplyProgress): string | null {
@ -380,17 +386,19 @@ function findLastProgressIndex(
function legacyCompletedOccurrenceKey( function legacyCompletedOccurrenceKey(
progress: ReplyProgress, progress: ReplyProgress,
entry: ProgressIndexEntry, entry: ProgressIndexEntry,
scope: number,
): string { ): string {
return `legacy-completed:${progressDisplayKey(progress)}\u0000${entry.firstOccurredAtMs}\u0000${entry.sequence}`; return `legacy-completed:${scope}:${progressDisplayKey(progress)}\u0000${entry.firstOccurredAtMs}\u0000${entry.sequence}`;
} }
interface ChatViewState { interface ChatViewState {
turns: ChatTurn[]; turns: ChatTurn[];
progressIndex: Map<string, ProgressIndexEntry>; progressIndex: Map<string, ProgressIndexEntry>;
progressScope: number;
} }
function emptyChatViewState(): ChatViewState { function emptyChatViewState(): ChatViewState {
return { turns: [], progressIndex: new Map() }; return { turns: [], progressIndex: new Map(), progressScope: 0 };
} }
function conversationCacheKey( function conversationCacheKey(
@ -402,14 +410,14 @@ function conversationCacheKey(
} }
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState { function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
let key = progressUpsertKey(progress); let key = progressUpsertKey(progress, state.progressScope);
let existingIndex = state.turns.findIndex( let existingIndex = state.turns.findIndex(
(turn) => turn.role === "progress" && turnProgressKey(turn) === key, (turn) => turn.role === "progress" && turnProgressKey(turn) === key,
); );
const activeKey = const activeKey =
!progress.progressId && progress.stage === "completed" !progress.progressId && progress.stage === "completed"
? legacyActiveProgressKey({ ...progress, stage: "started" }) ? legacyActiveProgressKey({ ...progress, stage: "started" }, state.progressScope)
: null; : null;
if (activeKey) { if (activeKey) {
const activeIndex = state.turns.findIndex( const activeIndex = state.turns.findIndex(
@ -425,7 +433,9 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView
if (completedDedupKey) { if (completedDedupKey) {
existingIndex = findLastProgressIndex( existingIndex = findLastProgressIndex(
state.turns, state.turns,
(turn) => legacyCompletedDedupKey(turn.progress) === completedDedupKey, (turn) =>
turnProgressKey(turn).startsWith(`legacy-completed:${state.progressScope}:`) &&
legacyCompletedDedupKey(turn.progress) === completedDedupKey,
); );
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null; const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
if (existingTurn?.role === "progress") { if (existingTurn?.role === "progress") {
@ -452,7 +462,7 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView
const nextEntry = { firstOccurredAtMs, sequence }; const nextEntry = { firstOccurredAtMs, sequence };
let nextKey = key; let nextKey = key;
if (!progress.progressId && progress.stage === "completed" && activeKey && key === activeKey) { if (!progress.progressId && progress.stage === "completed" && activeKey && key === activeKey) {
nextKey = legacyCompletedOccurrenceKey(merged, nextEntry); nextKey = legacyCompletedOccurrenceKey(merged, nextEntry, state.progressScope);
nextIndex.delete(activeKey); nextIndex.delete(activeKey);
} }
nextIndex.set(nextKey, nextEntry); nextIndex.set(nextKey, nextEntry);
@ -460,7 +470,7 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView
if (existingIndex >= 0) { if (existingIndex >= 0) {
const nextTurns = [...state.turns]; const nextTurns = [...state.turns];
nextTurns[existingIndex] = { role: "progress", progress: merged, progressKey: nextKey }; nextTurns[existingIndex] = { role: "progress", progress: merged, progressKey: nextKey };
return { turns: nextTurns, progressIndex: nextIndex }; return { ...state, turns: nextTurns, progressIndex: nextIndex };
} }
const insertAt = state.turns.findIndex((turn) => { const insertAt = state.turns.findIndex((turn) => {
@ -474,9 +484,10 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView
}); });
const nextTurn = { role: "progress" as const, progress: merged, progressKey: nextKey }; const nextTurn = { role: "progress" as const, progress: merged, progressKey: nextKey };
if (insertAt < 0) { if (insertAt < 0) {
return { turns: [...state.turns, nextTurn], progressIndex: nextIndex }; return { ...state, turns: [...state.turns, nextTurn], progressIndex: nextIndex };
} }
return { return {
...state,
turns: [ turns: [
...state.turns.slice(0, insertAt), ...state.turns.slice(0, insertAt),
nextTurn, nextTurn,
@ -549,8 +560,8 @@ function completeChatViewProgress(state: ChatViewState): ChatViewState {
stage: "completed" as const, stage: "completed" as const,
}; };
const nextKey = turn.progress.progressId const nextKey = turn.progress.progressId
? progressUpsertKey(completedProgress) ? progressUpsertKey(completedProgress, state.progressScope)
: legacyCompletedOccurrenceKey(completedProgress, entry); : legacyCompletedOccurrenceKey(completedProgress, entry, state.progressScope);
if (previousKey !== nextKey) nextIndex.delete(previousKey); if (previousKey !== nextKey) nextIndex.delete(previousKey);
nextIndex.set(nextKey, entry); nextIndex.set(nextKey, entry);
@ -569,7 +580,7 @@ function completeChatViewProgress(state: ChatViewState): ChatViewState {
}; };
}); });
return { turns, progressIndex: nextIndex }; return { ...state, turns, progressIndex: nextIndex };
} }
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean { function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
@ -751,8 +762,8 @@ function foldChunk(
const { turns } = state; const { turns } = state;
if (!isReplyRecord(raw)) { if (!isReplyRecord(raw)) {
return { return {
...state,
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }], turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
progressIndex: state.progressIndex,
}; };
} }
const last = turns[turns.length - 1]; const last = turns[turns.length - 1];
@ -777,26 +788,26 @@ function foldChunk(
switch (raw.kind) { switch (raw.kind) {
case "textDelta": case "textDelta":
return { return {
...state,
turns: appendAgentDelta(turns, String(raw.text ?? ""), receivedAtMs), turns: appendAgentDelta(turns, String(raw.text ?? ""), receivedAtMs),
progressIndex: state.progressIndex,
}; };
case "toolActivity": case "toolActivity":
return { return {
...state,
turns: [ turns: [
...turns, ...turns,
{ role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs }, { role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs },
], ],
progressIndex: state.progressIndex,
}; };
case "progress": { case "progress": {
const progress = normalizeProgress(raw.progress); const progress = normalizeProgress(raw.progress);
if (!progress) { if (!progress) {
return { return {
...state,
turns: [ turns: [
...turns, ...turns,
{ role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs },
], ],
progressIndex: state.progressIndex,
}; };
} }
return appendProgress(state, progress); return appendProgress(state, progress);
@ -812,7 +823,7 @@ function foldChunk(
text: content, text: content,
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs, receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
}); });
return { turns: next, progressIndex: completedState.progressIndex }; return { ...completedState, turns: next };
} }
case "error": { case "error": {
const completedState = completeChatViewProgress(state); const completedState = completeChatViewProgress(state);
@ -824,23 +835,24 @@ function foldChunk(
text: String(raw.message ?? "Erreur agent"), text: String(raw.message ?? "Erreur agent"),
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs, receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
}); });
return { turns: next, progressIndex: completedState.progressIndex }; return { ...completedState, turns: next };
} }
case "userPrompt": case "userPrompt":
case "UserPrompt": case "UserPrompt":
return { return {
...state,
turns: appendUserPrompt( turns: appendUserPrompt(
turns, turns,
String(raw.text ?? raw.prompt ?? ""), String(raw.text ?? raw.prompt ?? ""),
attachmentLabels(raw.attachments), attachmentLabels(raw.attachments),
receivedAtMs, receivedAtMs,
), ),
progressIndex: state.progressIndex, progressScope: state.progressScope + 1,
}; };
default: default:
return { return {
...state,
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }], turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
progressIndex: state.progressIndex,
}; };
} }
} }
@ -887,6 +899,14 @@ export function CustomAgentChatView({
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map()) ? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map())
: new Map(), : new Map(),
); );
const [progressScope, setProgressScope] = useState<number>(
() =>
sessionId
? (chatSessionViewCache.get(sessionId)?.progressScope ?? 0)
: initialConversationCacheKey
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressScope ?? 0)
: 0,
);
const [expandedProgressKeys, setExpandedProgressKeys] = useState<Set<string>>( const [expandedProgressKeys, setExpandedProgressKeys] = useState<Set<string>>(
() => new Set(), () => new Set(),
); );
@ -909,6 +929,7 @@ export function CustomAgentChatView({
const chatRootRef = useRef<HTMLDivElement | null>(null); const chatRootRef = useRef<HTMLDivElement | null>(null);
const turnsRef = useRef(turns); const turnsRef = useRef(turns);
const progressIndexRef = useRef(progressIndex); const progressIndexRef = useRef(progressIndex);
const progressScopeRef = useRef(progressScope);
const sessionRef = useRef<string | null>(sessionId); const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession; sessionRef.current = currentSession;
const scrollbackChunkCountRef = useRef( const scrollbackChunkCountRef = useRef(
@ -1001,8 +1022,10 @@ export function CustomAgentChatView({
const applyChatViewState = useCallback((next: ChatViewState) => { const applyChatViewState = useCallback((next: ChatViewState) => {
turnsRef.current = next.turns; turnsRef.current = next.turns;
progressIndexRef.current = next.progressIndex; progressIndexRef.current = next.progressIndex;
progressScopeRef.current = next.progressScope;
setTurns(next.turns); setTurns(next.turns);
setProgressIndex(next.progressIndex); setProgressIndex(next.progressIndex);
setProgressScope(next.progressScope);
}, []); }, []);
const updateChatViewState = useCallback( const updateChatViewState = useCallback(
@ -1011,6 +1034,7 @@ export function CustomAgentChatView({
updater({ updater({
turns: turnsRef.current, turns: turnsRef.current,
progressIndex: progressIndexRef.current, progressIndex: progressIndexRef.current,
progressScope: progressScopeRef.current,
}), }),
); );
}, },
@ -1037,15 +1061,32 @@ export function CustomAgentChatView({
const displayTurns = turns; const displayTurns = turns;
const sessionLabel = structuredSessionLabel(profile.structuredAdapter); const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
const [requesterDelegation, setRequesterDelegation] = const [requesterDelegation, setRequesterDelegation] =
useState<RequesterDelegationState>({ active: false }); useState<RequesterDelegationState>({
currentAgentBusy: false,
delegationTargetName: null,
});
const delegationActive = requesterDelegation.delegationTargetName !== null;
const effectiveBusy =
busy || requesterDelegation.currentAgentBusy || delegationActive;
const delegationFallbackText = requesterDelegation.delegationTargetName
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
: "Délégation en cours.";
const refreshRequesterDelegation = useCallback(async () => { const refreshRequesterDelegation = useCallback(async () => {
if (!workState) { if (!workState) {
setRequesterDelegation({ active: false }); setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
return; return;
} }
try { try {
const state = await workState.getProjectWorkState(projectId); const state = await workState.getProjectWorkState(projectId);
const current = state.agents.find((row) => row.agentId === agentId);
const currentAgentBusy = Boolean(
current?.busy.state === "busy" ||
current?.tickets.some((ticket) => ticket.status === "inProgress"),
);
const target = state.agents.find((row) => const target = state.agents.find((row) =>
row.tickets.some( row.tickets.some(
(ticket) => (ticket) =>
@ -1054,13 +1095,15 @@ export function CustomAgentChatView({
ticket.source.agentId === agentId, ticket.source.agentId === agentId,
), ),
); );
setRequesterDelegation( setRequesterDelegation({
target currentAgentBusy,
? { active: true } delegationTargetName: target ? workAgentDisplayName(target) : null,
: { active: false }, });
);
} catch { } catch {
setRequesterDelegation({ active: false }); setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
} }
}, [agentId, projectId, workState]); }, [agentId, projectId, workState]);
@ -1101,6 +1144,17 @@ export function CustomAgentChatView({
event.type === "agentInboxChanged" || event.type === "agentInboxChanged" ||
event.type === "agentWakeChanged" event.type === "agentWakeChanged"
) { ) {
if (
event.type === "agentBusyChanged" &&
event.agentId === agentId &&
!event.busy
) {
if (activeInteractionIdRef.current) {
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false);
}
void refreshRequesterDelegation(); void refreshRequesterDelegation();
} }
}) })
@ -1112,7 +1166,7 @@ export function CustomAgentChatView({
cancelled = true; cancelled = true;
unsubscribe?.(); unsubscribe?.();
}; };
}, [refreshRequesterDelegation, system]); }, [agentId, projectId, refreshRequesterDelegation, system]);
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
@ -1165,6 +1219,7 @@ export function CustomAgentChatView({
chatSessionViewCache.set(currentSession, { chatSessionViewCache.set(currentSession, {
turns, turns,
progressIndex, progressIndex,
progressScope,
chunkCount: scrollbackChunkCountRef.current, chunkCount: scrollbackChunkCountRef.current,
sessionId: currentSession, sessionId: currentSession,
}); });
@ -1176,12 +1231,13 @@ export function CustomAgentChatView({
chatConversationViewCache.set(currentConversationCacheKey, { chatConversationViewCache.set(currentConversationCacheKey, {
turns, turns,
progressIndex, progressIndex,
progressScope,
chunkCount: scrollbackChunkCountRef.current, chunkCount: scrollbackChunkCountRef.current,
...(cachedSessionId ? { sessionId: cachedSessionId } : {}), ...(cachedSessionId ? { sessionId: cachedSessionId } : {}),
}); });
} }
} }
}, [currentConversationCacheKey, currentSession, progressIndex, turns]); }, [currentConversationCacheKey, currentSession, progressIndex, progressScope, turns]);
useEffect(() => { useEffect(() => {
draftSaveSeqRef.current += 1; draftSaveSeqRef.current += 1;
@ -1261,7 +1317,7 @@ export function CustomAgentChatView({
!supported || !supported ||
!agent.listSlashCommands || !agent.listSlashCommands ||
slashPrefix === null || slashPrefix === null ||
busy || effectiveBusy ||
opening opening
) { ) {
setSlashMenuOpen(false); setSlashMenuOpen(false);
@ -1286,7 +1342,7 @@ export function CustomAgentChatView({
setSlashMenuOpen(false); setSlashMenuOpen(false);
setSlashActiveIndex(0); setSlashActiveIndex(0);
}); });
}, [agent, busy, opening, slashPrefix, supported]); }, [agent, effectiveBusy, opening, slashPrefix, supported]);
const selectSlashCommand = useCallback((command: SlashCommand) => { const selectSlashCommand = useCallback((command: SlashCommand) => {
if (!isSlashCommandAvailable(command)) return; if (!isSlashCommandAvailable(command)) return;
@ -1492,6 +1548,7 @@ export function CustomAgentChatView({
applyChatViewState({ applyChatViewState({
turns: cached.turns, turns: cached.turns,
progressIndex: cached.progressIndex ?? new Map(), progressIndex: cached.progressIndex ?? new Map(),
progressScope: cached.progressScope ?? 0,
}); });
} }
try { try {
@ -1526,6 +1583,7 @@ export function CustomAgentChatView({
applyChatViewState({ applyChatViewState({
turns: cached.turns, turns: cached.turns,
progressIndex: cached.progressIndex ?? new Map(), progressIndex: cached.progressIndex ?? new Map(),
progressScope: cached.progressScope ?? 0,
}); });
if (cached.sessionId) { if (cached.sessionId) {
const reattachedSessionId = await reattachStructuredSession(cached.sessionId, { const reattachedSessionId = await reattachStructuredSession(cached.sessionId, {
@ -1588,9 +1646,9 @@ export function CustomAgentChatView({
() => () =>
supported && supported &&
(Boolean(draft.trim()) || attachments.length > 0) && (Boolean(draft.trim()) || attachments.length > 0) &&
!busy && !effectiveBusy &&
!opening, !opening,
[supported, draft, attachments.length, busy, opening], [supported, draft, attachments.length, effectiveBusy, opening],
); );
async function openProfileDialog() { async function openProfileDialog() {
@ -1670,6 +1728,7 @@ export function CustomAgentChatView({
}, },
], ],
progressIndex: new Map(), progressIndex: new Map(),
progressScope: 0,
}); });
setProfileDialog(null); setProfileDialog(null);
} catch (e) { } catch (e) {
@ -1706,8 +1765,8 @@ export function CustomAgentChatView({
if (result.effect.kind === "help") { if (result.effect.kind === "help") {
const helpText = formatSlashCommandHelp(result.effect.commands); const helpText = formatSlashCommandHelp(result.effect.commands);
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: Date.now() }], turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: Date.now() }],
progressIndex: prev.progressIndex,
})); }));
return; return;
} }
@ -1726,6 +1785,7 @@ export function CustomAgentChatView({
...result.effect.arguments, ...result.effect.arguments,
); );
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
...prev,
turns: [ turns: [
...prev.turns, ...prev.turns,
{ {
@ -1734,7 +1794,6 @@ export function CustomAgentChatView({
receivedAtMs: Date.now(), receivedAtMs: Date.now(),
}, },
], ],
progressIndex: prev.progressIndex,
})); }));
return; return;
} }
@ -1742,8 +1801,8 @@ export function CustomAgentChatView({
const message = `Commande ${invocation.name} échouée: ${describe(e)}`; const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
setError({ message, openFailure: false }); setError({ message, openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }], turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }],
progressIndex: prev.progressIndex,
})); }));
} }
} }
@ -1859,6 +1918,7 @@ export function CustomAgentChatView({
setError(null); setError(null);
const sentAtMs = Date.now(); const sentAtMs = Date.now();
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
...prev,
turns: [ turns: [
...prev.turns, ...prev.turns,
{ {
@ -1868,7 +1928,7 @@ export function CustomAgentChatView({
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
}, },
], ],
progressIndex: prev.progressIndex, progressScope: prev.progressScope + 1,
})); }));
const sendTurn = (sid: string) => const sendTurn = (sid: string) =>
attachmentInputs.length > 0 attachmentInputs.length > 0
@ -1900,11 +1960,11 @@ export function CustomAgentChatView({
setBusy(false); setBusy(false);
setError({ message: describe(recoveryError), openFailure: false }); setError({ message: describe(recoveryError), openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
...prev,
turns: [ turns: [
...prev.turns, ...prev.turns,
{ role: "error", text: describe(recoveryError), receivedAtMs: Date.now() }, { role: "error", text: describe(recoveryError), receivedAtMs: Date.now() },
], ],
progressIndex: prev.progressIndex,
})); }));
return; return;
} }
@ -1913,8 +1973,8 @@ export function CustomAgentChatView({
setBusy(false); setBusy(false);
setError({ message: describe(e), openFailure: false }); setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }], turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }],
progressIndex: prev.progressIndex,
})); }));
} }
} }
@ -1937,6 +1997,7 @@ export function CustomAgentChatView({
updateChatViewState((prev) => { updateChatViewState((prev) => {
const completed = completeChatViewProgress(prev); const completed = completeChatViewProgress(prev);
return { return {
...completed,
turns: [ turns: [
...completed.turns, ...completed.turns,
{ {
@ -1948,7 +2009,6 @@ export function CustomAgentChatView({
receivedAtMs: Date.now(), receivedAtMs: Date.now(),
}, },
], ],
progressIndex: completed.progressIndex,
}; };
}); });
} catch (e) { } catch (e) {
@ -1964,17 +2024,18 @@ export function CustomAgentChatView({
updateChatViewState((prev) => { updateChatViewState((prev) => {
const completed = completeChatViewProgress(prev); const completed = completeChatViewProgress(prev);
return { return {
...completed,
turns: [ turns: [
...completed.turns, ...completed.turns,
{ role: "tool", label: "Session déjà arrêtée.", receivedAtMs: Date.now() }, { role: "tool", label: "Session déjà arrêtée.", receivedAtMs: Date.now() },
], ],
progressIndex: completed.progressIndex,
}; };
}); });
return; return;
} }
setError({ message: describe(e), openFailure: false }); setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
...prev,
turns: [ turns: [
...prev.turns, ...prev.turns,
{ {
@ -1983,7 +2044,6 @@ export function CustomAgentChatView({
receivedAtMs: Date.now(), receivedAtMs: Date.now(),
}, },
], ],
progressIndex: prev.progressIndex,
})); }));
} finally { } finally {
setStopping(false); setStopping(false);
@ -2159,8 +2219,8 @@ export function CustomAgentChatView({
<div className="shrink-0"> <div className="shrink-0">
<AnnouncementsPreview <AnnouncementsPreview
requester={agentId} requester={agentId}
fallbackActive={requesterDelegation.active} fallbackActive={delegationActive}
fallbackText="Délégation en cours." fallbackText={delegationFallbackText}
/> />
</div> </div>
{opening && displayTurns.length === 0 ? ( {opening && displayTurns.length === 0 ? (
@ -2286,7 +2346,7 @@ export function CustomAgentChatView({
)} )}
rows={2} rows={2}
value={draft} value={draft}
disabled={!supported || opening || busy} disabled={!supported || opening || effectiveBusy}
placeholder="Message à l'agent…" placeholder="Message à l'agent…"
style={{ height: clampComposerHeight(composerHeightPx) }} style={{ height: clampComposerHeight(composerHeightPx) }}
onChange={(e) => editDraft(e.target.value)} onChange={(e) => editDraft(e.target.value)}
@ -2343,24 +2403,24 @@ export function CustomAgentChatView({
size="sm" size="sm"
variant="ghost" variant="ghost"
className="shrink-0 whitespace-nowrap" className="shrink-0 whitespace-nowrap"
disabled={!supported || opening || busy} disabled={!supported || opening || effectiveBusy}
onClick={() => void pickAttachment()} onClick={() => void pickAttachment()}
> >
Joindre Joindre
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant={busy ? "danger" : undefined} variant={effectiveBusy ? "danger" : undefined}
className="shrink-0 whitespace-nowrap" className="shrink-0 whitespace-nowrap"
disabled={ disabled={
busy effectiveBusy
? !supported || !currentSession || !agent.stopLiveAgent || stopping ? !supported || !currentSession || !agent.stopLiveAgent || stopping
: !canSend : !canSend
} }
loading={stopping} loading={stopping}
onClick={() => (busy ? void stopAgent() : void send())} onClick={() => (effectiveBusy ? void stopAgent() : void send())}
> >
{stopping ? "Arrêt en cours..." : busy ? "Stop" : "Envoyer"} {stopping ? "Arrêt en cours..." : effectiveBusy ? "Stop" : "Envoyer"}
</Button> </Button>
</div> </div>
</div> </div>