merge(application,backend,app-tauri,frontend): fix #357 — réconcilie l'état idle et les Finals après Stop (QA verte)

This commit is contained in:
2026-09-14 15:13:12 +02:00
8 changed files with 422 additions and 38 deletions

View File

@ -2901,6 +2901,15 @@ pub async fn agent_send(
"agent_send",
)
});
if let Some((agent, ticket)) = turn_guard
.as_ref()
.and_then(backend::StructuredHumanTurnGuard::accounting_identity)
{
let _ =
state
.structured_sessions
.bind_turn_accounting(&sid, turn_generation, agent, ticket);
}
// Retain the human submit in the same live scrollback as model chunks so
// `reattach_agent_chat` can repaint the whole in-flight conversation.

View File

@ -10,8 +10,11 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use domain::conversation::ConversationId;
use domain::mailbox::TicketId;
use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, IssueRef, NodeId, ProjectId, SessionId, SessionKind, TerminalSession};
use domain::{
AgentId, IssueRef, NodeId, ProjectId, RuntimeAgentKey, SessionId, SessionKind, TerminalSession,
};
use crate::model_server::ModelServerUseGuard;
@ -541,6 +544,8 @@ struct StructuredEntry {
turn_generation: u64,
/// Current/last logical-turn state for idempotent request cancellation.
turn_state: StructuredTurnState,
/// Exact mediated ticket owned by the active generation, when it acquired one.
turn_accounting: Option<(u64, RuntimeAgentKey, TicketId)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -673,6 +678,7 @@ impl StructuredSessions {
_model_server_guard: model_server_guard,
turn_generation: 0,
turn_state: StructuredTurnState::Completed,
turn_accounting: None,
},
);
}
@ -712,10 +718,31 @@ impl StructuredSessions {
entry.turn_generation = entry.turn_generation.wrapping_add(1).max(1);
let generation = entry.turn_generation;
entry.turn_state = StructuredTurnState::Active(generation);
entry.turn_accounting = None;
Some(generation)
})
}
/// Correlates one logical provider generation with its mediated busy/FIFO ticket.
pub fn bind_turn_accounting(
&self,
id: &SessionId,
generation: u64,
agent: RuntimeAgentKey,
ticket: TicketId,
) -> bool {
self.entries.lock().ok().is_some_and(|mut entries| {
let Some(entry) = entries.get_mut(id) else {
return false;
};
if entry.turn_state != StructuredTurnState::Active(generation) {
return false;
}
entry.turn_accounting = Some((generation, agent, ticket));
true
})
}
/// Atomically records a cancellation request and returns the live session.
pub fn request_turn_cancel(
&self,
@ -723,6 +750,7 @@ impl StructuredSessions {
) -> Option<(
Arc<dyn AgentSession>,
domain::ports::CancelCurrentTurnOutcome,
Option<(RuntimeAgentKey, TicketId)>,
)> {
self.entries.lock().ok().and_then(|mut entries| {
let entry = entries.get_mut(id)?;
@ -738,7 +766,14 @@ impl StructuredSessions {
domain::ports::CancelCurrentTurnOutcome::AlreadyCompleted
}
};
Some((Arc::clone(&entry.session), outcome))
let accounting = match entry.turn_state {
StructuredTurnState::Cancelled(generation) => entry
.turn_accounting
.filter(|(bound, _, _)| *bound == generation)
.map(|(_, agent, ticket)| (agent, ticket)),
StructuredTurnState::Completed | StructuredTurnState::Active(_) => None,
};
Some((Arc::clone(&entry.session), outcome, accounting))
})
}
@ -758,6 +793,7 @@ impl StructuredSessions {
if let Some(entry) = entries.get_mut(id) {
if entry.turn_state == StructuredTurnState::Active(generation) {
entry.turn_state = StructuredTurnState::Completed;
entry.turn_accounting = None;
}
}
}

View File

@ -2,6 +2,8 @@
use std::sync::Arc;
use domain::input::InputMediator;
use domain::mailbox::AgentMailbox;
use domain::ports::{CancelCurrentTurnOutcome, EventBus, PtyPort, SpawnSpec};
use domain::{
AgentId, DomainEvent, NodeId, Project, ProjectPath, PtySize, SessionId, SessionKind,
@ -27,13 +29,28 @@ pub struct CancelAgentChatOutput {
/// Cancels one structured chat turn without stopping its reusable runtime.
pub struct CancelAgentChat {
sessions: Arc<StructuredSessions>,
turn_accounting: Option<(Arc<dyn InputMediator>, Arc<dyn AgentMailbox>)>,
}
impl CancelAgentChat {
/// Builds the use case from the structured runtime registry.
#[must_use]
pub fn new(sessions: Arc<StructuredSessions>) -> Self {
Self { sessions }
Self {
sessions,
turn_accounting: None,
}
}
/// Wires the mediated busy/FIFO state owned by direct human turns.
#[must_use]
pub fn with_turn_accounting(
mut self,
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn AgentMailbox>,
) -> Self {
self.turn_accounting = Some((input, mailbox));
self
}
/// Requests cancellation from the live session and deliberately leaves its
@ -43,7 +60,7 @@ impl CancelAgentChat {
/// Returns [`AppError::NotFound`] only when the session itself is absent, and
/// a process error when the adapter could not interrupt its current child.
pub async fn execute(&self, session_id: SessionId) -> Result<CancelAgentChatOutput, AppError> {
let (session, outcome) = self
let (session, outcome, active_ticket) = self
.sessions
.request_turn_cancel(&session_id)
.ok_or_else(|| AppError::NotFound(format!("structured session {session_id}")))?;
@ -52,6 +69,18 @@ impl CancelAgentChat {
self.sessions.restore_turn_after_cancel_failure(&session_id);
return Err(AppError::from(error));
}
if let (Some((agent, ticket)), Some((input, mailbox))) =
(active_ticket, self.turn_accounting.as_ref())
{
// The provider kill can race the old pump's normal guard cleanup and
// a caller may already have started the next turn. Retire only the
// exact ticket observed before cancellation; never mark a successor
// idle when it has already replaced that ticket.
if input.busy_state(agent).ticket() == Some(ticket) {
mailbox.cancel_head(agent, ticket);
input.mark_idle(agent);
}
}
}
Ok(CancelAgentChatOutput {
session_id,

View File

@ -2254,11 +2254,17 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
assert!(doc.contains("# persona"), "persona present: {doc}");
assert!(
doc.contains("**refactor** — REFAC_BODY"),
doc.contains(&format!(
"**refactor** (`skill:global:{}`) — REFAC_BODY (workflow)",
skill_id(1)
)),
"first skill affordance present"
);
assert!(
doc.contains("**review** — REVIEW_BODY"),
doc.contains(&format!(
"**review** (`skill:global:{}`) — REVIEW_BODY (workflow)",
skill_id(2)
)),
"second skill affordance present"
);
// Durable capability-first order: skills before persona, assignments stable.

View File

@ -5,13 +5,16 @@ use application::{
CancelAgentChat, LiveRuntimeState, LiveSessions, StructuredSessions, TerminalSessions,
};
use async_trait::async_trait;
use domain::input::{AgentBusyState, InputMediator, SubmitConfig};
use domain::mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
use domain::ports::{AgentSession, AgentSessionError, CancelCurrentTurnOutcome, ReplyStream};
use domain::{AgentId, NodeId, ProjectId, SessionId};
use domain::{AgentId, NodeId, ProjectId, RuntimeAgentKey, SessionId};
use uuid::Uuid;
struct FakeSession {
id: SessionId,
cancellations: Mutex<VecDeque<CancelCurrentTurnOutcome>>,
cancel_hook: Mutex<Option<Box<dyn FnOnce() + Send>>>,
sends: Mutex<Vec<String>>,
shutdowns: Mutex<usize>,
}
@ -21,10 +24,16 @@ impl FakeSession {
Self {
id,
cancellations: Mutex::new(outcomes.into_iter().collect()),
cancel_hook: Mutex::new(None),
sends: Mutex::new(Vec::new()),
shutdowns: Mutex::new(0),
}
}
fn with_cancel_hook(self, hook: impl FnOnce() + Send + 'static) -> Self {
*self.cancel_hook.lock().unwrap() = Some(Box::new(hook));
self
}
}
#[async_trait]
@ -43,12 +52,16 @@ impl AgentSession for FakeSession {
}
async fn cancel_current_turn(&self) -> Result<CancelCurrentTurnOutcome, AgentSessionError> {
Ok(self
let outcome = self
.cancellations
.lock()
.unwrap()
.pop_front()
.unwrap_or(CancelCurrentTurnOutcome::AlreadyCompleted))
.unwrap_or(CancelCurrentTurnOutcome::AlreadyCompleted);
if let Some(hook) = self.cancel_hook.lock().unwrap().take() {
hook();
}
Ok(outcome)
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
@ -61,6 +74,58 @@ fn id<T>(value: u128, make: impl FnOnce(Uuid) -> T) -> T {
make(Uuid::from_u128(value))
}
struct TurnAccounting {
state: Mutex<AgentBusyState>,
cancelled: Mutex<Vec<(RuntimeAgentKey, TicketId)>>,
}
impl Default for TurnAccounting {
fn default() -> Self {
Self {
state: Mutex::new(AgentBusyState::Idle),
cancelled: Mutex::new(Vec::new()),
}
}
}
impl InputMediator for TurnAccounting {
fn enqueue(&self, _agent: RuntimeAgentKey, _ticket: Ticket) -> PendingReply {
panic!("not used")
}
fn bind_handle_with_submit(
&self,
_agent: RuntimeAgentKey,
_handle: domain::ports::PtyHandle,
_submit: SubmitConfig,
) {
}
fn preempt(&self, _agent: RuntimeAgentKey) {}
fn mark_idle(&self, _agent: RuntimeAgentKey) {
*self.state.lock().unwrap() = AgentBusyState::Idle;
}
fn busy_state(&self, _agent: RuntimeAgentKey) -> AgentBusyState {
*self.state.lock().unwrap()
}
}
impl AgentMailbox for TurnAccounting {
fn enqueue(&self, _agent: RuntimeAgentKey, _ticket: Ticket) -> PendingReply {
panic!("not used")
}
fn resolve(&self, agent: RuntimeAgentKey, _result: String) -> Result<(), MailboxError> {
Err(MailboxError::NoPendingRequest(agent.agent_id))
}
fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
self.cancelled.lock().unwrap().push((agent, ticket_id));
}
}
#[tokio::test]
async fn cancel_keeps_session_and_accepts_a_second_prompt_without_shutdown() {
let session_id = id(1, SessionId::from_uuid);
@ -140,3 +205,89 @@ async fn double_cancel_is_typed_and_missing_session_remains_distinct() {
assert_eq!(error.code(), "NOT_FOUND");
assert!(sessions.session(&session_id).is_some());
}
#[tokio::test]
async fn cancel_retires_the_exact_busy_turn_before_the_next_prompt() {
let session_id = id(20, SessionId::from_uuid);
let project_id = id(21, ProjectId::from_uuid);
let agent_id = id(22, AgentId::from_uuid);
let ticket = TicketId::from_uuid(Uuid::from_u128(23));
let accounting = Arc::new(TurnAccounting::default());
*accounting.state.lock().unwrap() = AgentBusyState::Busy {
ticket,
since_ms: 1,
};
let session = Arc::new(FakeSession::new(
session_id,
[CancelCurrentTurnOutcome::Cancelled],
));
let sessions = Arc::new(StructuredSessions::new());
sessions.insert_in_project(project_id, session, agent_id, id(24, NodeId::from_uuid));
let generation = sessions.begin_turn(&session_id).unwrap();
assert!(sessions.bind_turn_accounting(
&session_id,
generation,
RuntimeAgentKey::new(project_id, agent_id),
ticket,
));
let input = Arc::clone(&accounting) as Arc<dyn InputMediator>;
let mailbox = Arc::clone(&accounting) as Arc<dyn AgentMailbox>;
let cancel = CancelAgentChat::new(Arc::clone(&sessions)).with_turn_accounting(input, mailbox);
let output = cancel.execute(session_id).await.unwrap();
assert_eq!(output.outcome, CancelCurrentTurnOutcome::Cancelled);
assert_eq!(*accounting.state.lock().unwrap(), AgentBusyState::Idle);
assert_eq!(
*accounting.cancelled.lock().unwrap(),
[(RuntimeAgentKey::new(project_id, agent_id), ticket)]
);
}
#[tokio::test]
async fn late_cancel_cleanup_does_not_mark_a_successor_ticket_idle() {
let session_id = id(30, SessionId::from_uuid);
let project_id = id(31, ProjectId::from_uuid);
let agent_id = id(32, AgentId::from_uuid);
let cancelled_ticket = TicketId::from_uuid(Uuid::from_u128(33));
let successor_ticket = TicketId::from_uuid(Uuid::from_u128(34));
let accounting = Arc::new(TurnAccounting::default());
*accounting.state.lock().unwrap() = AgentBusyState::Busy {
ticket: cancelled_ticket,
since_ms: 1,
};
let accounting_during_cancel = Arc::clone(&accounting);
let session = Arc::new(
FakeSession::new(session_id, [CancelCurrentTurnOutcome::Cancelled]).with_cancel_hook(
move || {
*accounting_during_cancel.state.lock().unwrap() = AgentBusyState::Busy {
ticket: successor_ticket,
since_ms: 2,
};
},
),
);
let sessions = Arc::new(StructuredSessions::new());
sessions.insert_in_project(project_id, session, agent_id, id(35, NodeId::from_uuid));
let generation = sessions.begin_turn(&session_id).unwrap();
assert!(sessions.bind_turn_accounting(
&session_id,
generation,
RuntimeAgentKey::new(project_id, agent_id),
cancelled_ticket,
));
let input = Arc::clone(&accounting) as Arc<dyn InputMediator>;
let mailbox = Arc::clone(&accounting) as Arc<dyn AgentMailbox>;
let cancel = CancelAgentChat::new(Arc::clone(&sessions)).with_turn_accounting(input, mailbox);
cancel.execute(session_id).await.unwrap();
assert_eq!(
*accounting.state.lock().unwrap(),
AgentBusyState::Busy {
ticket: successor_ticket,
since_ms: 2,
}
);
assert!(accounting.cancelled.lock().unwrap().is_empty());
}

View File

@ -1774,6 +1774,16 @@ impl StructuredHumanTurnGuard {
}
}
/// Exact mediated ticket owned by this logical human turn, if it acquired Busy.
#[must_use]
pub fn accounting_identity(
&self,
) -> Option<(domain::RuntimeAgentKey, domain::mailbox::TicketId)> {
self.armed
.load(std::sync::atomic::Ordering::Acquire)
.then_some((self.agent, self.ticket_id))
}
/// Resolves whether a concrete provider terminal event really ends the
/// logical human turn. During self-restart it waits for the replacement
/// session, then acknowledges the handoff so the scheduler can complete.
@ -2757,7 +2767,6 @@ impl BackendCore {
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
// ChangeAgentProfile (shutdown polymorphe au hot-swap).
let structured_sessions = Arc::new(StructuredSessions::new());
let cancel_agent_chat = Arc::new(CancelAgentChat::new(Arc::clone(&structured_sessions)));
let live_sessions = Arc::new(LiveSessions::new(
Arc::clone(&terminal_sessions),
Arc::clone(&structured_sessions),
@ -3739,6 +3748,10 @@ impl BackendCore {
});
}
let input_mediator = Arc::clone(&mediated_inbox) as Arc<dyn domain::input::InputMediator>;
let cancel_agent_chat = Arc::new(
CancelAgentChat::new(Arc::clone(&structured_sessions))
.with_turn_accounting(Arc::clone(&input_mediator), Arc::clone(&mailbox)),
);
let background_tasks = Arc::new(AppBackgroundTaskStore::new(Arc::clone(&store_port)));
let background_tasks_port = Arc::clone(&background_tasks) as Arc<dyn BackgroundTaskStore>;
let (background_ready_tx, mut background_ready_rx) =

View File

@ -1780,6 +1780,112 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
});
it("keeps a following turn isolated from a late terminal callback of the previous turn (#357)", async () => {
const harness = createStreamingAgentHarness();
render(
<DIProvider
gateways={{
agent: harness.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>,
);
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
fireEvent.change(composer, { target: { value: "first turn" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(harness.emitterCount()).toBe(1));
act(() => harness.emit(0, { kind: "final", content: "First final" }));
await screen.findByText("First final");
fireEvent.change(composer, { target: { value: "second turn" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(harness.emitterCount()).toBe(2));
act(() => harness.emit(0, { kind: "final", content: "Late first final" }));
expect(screen.queryByText("Late first final")).toBeNull();
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
act(() => harness.emit(1, { kind: "final", content: "Second final" }));
expect(await screen.findByText("Second final")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
});
it("reconciles an active composer from the terminal work-state and replaces its fallback with a late Final (#357)", async () => {
const harness = createStreamingAgentHarness();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
const stateFor = (busy: boolean) => ({
agents: [
{
agentId: "agent-1",
name: "Worker",
profileId: "structured",
busy: busy
? ({ state: "busy", ticket: "turn-357", sinceMs: 1 } as const)
: ({ state: "idle" } as const),
tickets: [],
},
],
conversations: [],
});
workState._setProjectWorkState("project-1", stateFor(false));
render(
<DIProvider
gateways={{ agent: harness.agent, system, workState } 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>,
);
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
await waitFor(() => expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy());
workState._setProjectWorkState("project-1", stateFor(true));
fireEvent.change(composer, { target: { value: "turn without projected final" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(harness.emitterCount()).toBe(1));
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
workState._setProjectWorkState("project-1", stateFor(false));
act(() => system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false }));
expect(await screen.findByText("Tour terminé sans réponse finale.")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
act(() => harness.emit(0, { kind: "final", content: "Recovered late final" }));
expect(await screen.findByText("Recovered late final")).toBeTruthy();
expect(screen.queryByText("Tour terminé sans réponse finale.")).toBeNull();
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
});
it.each(["alreadyCancelled", "alreadyCompleted"] as const)(
"treats backend outcome %s as one already-stopped terminal result",
async (outcome) => {

View File

@ -104,6 +104,10 @@ const chatMessageQueueCache = new Map<string, QueuedChatMessage[]>();
const CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT = 24;
const CHAT_MESSAGE_QUEUE_CACHE_LIMIT = 24;
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
const MODEL_RESPONSE_NOT_FINALIZED_MESSAGE =
"La réponse du modèle n'a pas pu être finalisée.";
const TURN_FINISHED_WITHOUT_FINAL_MESSAGE =
"Tour terminé sans réponse finale.";
const COMPOSER_RESIZE_STEP = 12;
const FALLBACK_COMPOSER_HEIGHT = 64;
const COMPOSER_HISTORY_LIMIT = 100;
@ -717,7 +721,11 @@ function completeChatViewProgress(state: ChatViewState): ChatViewState {
}
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
return turn?.role === "error" && turn.text === EMPTY_MODEL_RESPONSE_MESSAGE;
return (
turn?.role === "error" &&
(turn.text === EMPTY_MODEL_RESPONSE_MESSAGE ||
turn.text === TURN_FINISHED_WITHOUT_FINAL_MESSAGE)
);
}
function turnsIndicateBusy(state: ChatViewState): boolean {
@ -949,7 +957,10 @@ function foldChunk(
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({
role: "error",
text: String(raw.message ?? "Erreur agent"),
text:
String(raw.message ?? "Erreur agent") === MODEL_RESPONSE_NOT_FINALIZED_MESSAGE
? TURN_FINISHED_WITHOUT_FINAL_MESSAGE
: String(raw.message ?? "Erreur agent"),
receivedAtMs: emittedAtMs,
});
return { ...completedState, turns: next };
@ -1097,6 +1108,7 @@ export function CustomAgentChatView({
const draftSaveSeqRef = useRef(0);
const interactionSeqRef = useRef(0);
const activeInteractionIdRef = useRef<string | null>(null);
const readModelSettledInteractionIdRef = useRef<string | null>(null);
const transmissionInFlightRef = useRef(false);
const cancelledInteractionIdsRef = useRef(new Set<string>());
const ignoreUnscopedChunksAfterStopRef = useRef(false);
@ -1290,6 +1302,7 @@ export function CustomAgentChatView({
const refreshRequesterDelegation = useCallback(async () => {
const refreshSeq = ++requesterRefreshSeqRef.current;
const interactionAtRefresh = activeInteractionIdRef.current;
if (!workState) {
if (refreshSeq !== requesterRefreshSeqRef.current) return;
setRequesterDelegation({
@ -1322,6 +1335,26 @@ export function CustomAgentChatView({
currentAgentBusy,
delegationTargetName: target ? workAgentDisplayName(target) : null,
});
if (
interactionAtRefresh &&
activeInteractionIdRef.current === interactionAtRefresh &&
!currentAgentBusy &&
!target
) {
markProjectCliInteractionFinished(projectId, interactionAtRefresh);
activeInteractionIdRef.current = null;
readModelSettledInteractionIdRef.current = interactionAtRefresh;
stopTerminalOverrideRef.current = true;
setStopTerminalOverride(true);
setBusy(false);
setQueueDrainReady(false);
updateChatViewState((previous) =>
foldChunk(previous, {
kind: "error",
message: TURN_FINISHED_WITHOUT_FINAL_MESSAGE,
}),
);
}
if (stopTerminalOverrideRef.current && !currentAgentBusy && !target) {
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
@ -1333,7 +1366,7 @@ export function CustomAgentChatView({
delegationTargetName: null,
});
}
}, [agentId, projectId, workState]);
}, [agentId, projectId, updateChatViewState, workState]);
const clearPersistedDraft = useCallback(async () => {
if (!draftScope) return;
@ -1510,17 +1543,9 @@ export function CustomAgentChatView({
event.type === "agentInboxChanged" ||
event.type === "agentWakeChanged"
) {
if (
event.type === "agentBusyChanged" &&
event.agentId === agentId &&
!event.busy
) {
if (activeInteractionIdRef.current) {
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false);
}
// Reconcile through the current work-state snapshot below. A domain
// event carries no turn id and may belong to a previously cancelled
// generation, so it must never settle a newer local turn directly.
void refreshRequesterDelegation();
}
})
@ -1590,11 +1615,18 @@ export function CustomAgentChatView({
const receive = useCallback(
(chunk: ReplyChunk, sourceInteractionId?: string) => {
if (
sourceInteractionId &&
cancelledInteractionIdsRef.current.has(sourceInteractionId)
) {
return;
if (sourceInteractionId) {
const isLateTerminalForReadModelSettledTurn =
activeInteractionIdRef.current === null &&
readModelSettledInteractionIdRef.current === sourceInteractionId &&
(chunk.kind === "final" || chunk.kind === "error");
if (
cancelledInteractionIdsRef.current.has(sourceInteractionId) ||
(activeInteractionIdRef.current !== sourceInteractionId &&
!isLateTerminalForReadModelSettledTurn)
) {
return;
}
}
if (!sourceInteractionId && ignoreUnscopedChunksAfterStopRef.current) {
if (chunk.kind === "userPrompt") {
@ -1617,13 +1649,12 @@ export function CustomAgentChatView({
) {
markProjectCliInteractionStarted(projectId, activeInteractionId);
}
if (chunk.progress.stage === "completed") {
markProjectCliInteractionFinished(projectId, activeInteractionId);
activeInteractionIdRef.current = null;
}
}
updateChatViewState((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") {
if (sourceInteractionId) {
readModelSettledInteractionIdRef.current = null;
}
setQueueDrainReady(chunk.kind === "final");
if (activeInteractionIdRef.current) {
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
@ -2505,11 +2536,15 @@ export function CustomAgentChatView({
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
activeInteractionIdRef.current = interactionId;
readModelSettledInteractionIdRef.current = null;
markProjectCliInteractionStarted(projectId, interactionId);
const finishInteraction = () => {
const failInteraction = () => {
if (activeInteractionIdRef.current !== interactionId) return;
markProjectCliInteractionFinished(projectId, interactionId);
activeInteractionIdRef.current = null;
if (readModelSettledInteractionIdRef.current === interactionId) {
readModelSettledInteractionIdRef.current = null;
}
};
threadScroll.followLatest();
setBusy(true);
@ -2542,7 +2577,6 @@ export function CustomAgentChatView({
}));
await sendTurn(sid);
if (cancelledInteractionIdsRef.current.has(interactionId)) return;
finishInteraction();
} catch (e) {
if (cancelledInteractionIdsRef.current.has(interactionId)) return;
if (isNotFound(e)) {
@ -2554,10 +2588,9 @@ export function CustomAgentChatView({
retryAttachNotFound: true,
});
await sendTurn(recoveredSession);
finishInteraction();
return;
} catch (recoveryError) {
finishInteraction();
failInteraction();
setBusy(false);
setQueueDrainReady(false);
setError({ message: describe(recoveryError), openFailure: false });
@ -2571,7 +2604,7 @@ export function CustomAgentChatView({
return;
}
}
finishInteraction();
failInteraction();
setBusy(false);
setQueueDrainReady(false);
setError({ message: describe(e), openFailure: false });
@ -2618,6 +2651,7 @@ export function CustomAgentChatView({
setQueueDrainReady(false);
ignoreUnscopedChunksAfterStopRef.current = true;
const interactionBeingCancelled = activeInteractionIdRef.current;
readModelSettledInteractionIdRef.current = null;
if (interactionBeingCancelled) {
cancelledInteractionIdsRef.current.add(interactionBeingCancelled);
}