From da18c0a5ef29503849738324dddf5b3cb32dec88 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 21 Aug 2026 08:07:55 -0700 Subject: [PATCH] [eric] agents: the Resume chip is a human, so a stopped chat honours it (ENG-384) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018foyDoK19jjbYdudfzQVkZ --- backend/apps/agents/agents.py | 1 + backend/apps/agents/manager/Messaging.py | 11 ++++--- backend/tests/test_user_stop_is_final.py | 33 +++++++++++++++++++ .../src/app/pages/AgentChat/AgentChat.tsx | 2 ++ frontend/src/shared/state/agentsSlice.ts | 6 ++-- 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 597e0a93..5d912bdc 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -212,6 +212,7 @@ async def send_message(session_id: str, body: dict): forced_tools=body.get("forced_tools"), attached_skills=body.get("attached_skills"), hidden=body.get("hidden", False), + by_user=body.get("by_user", False), selected_browser_ids=body.get("selected_browser_ids"), selected_app_output_ids=body.get("selected_app_output_ids"), selected_setting_ids=body.get("selected_setting_ids"), diff --git a/backend/apps/agents/manager/Messaging.py b/backend/apps/agents/manager/Messaging.py index f5fc572e..e460dec3 100644 --- a/backend/apps/agents/manager/Messaging.py +++ b/backend/apps/agents/manager/Messaging.py @@ -60,6 +60,7 @@ class Messaging(AgentManagerProtocol): forced_tools: Optional[List[str]] = None, attached_skills: Optional[List] = None, hidden: bool = False, + by_user: bool = False, selected_browser_ids: Optional[List[str]] = None, selected_app_output_ids: Optional[List[str]] = None, selected_setting_ids: Optional[List[str]] = None, @@ -71,18 +72,18 @@ class Messaging(AgentManagerProtocol): data = load_session_data(session_id) if data: session = AgentSession(**data) - # This disk reload (and the closed_at wipe below) is how a late watchdog retry reopened a card the user had closed; a machine send must not revive it. - if hidden and session.ended_by_user: + # This disk reload (and the closed_at wipe below) is how a late watchdog retry reopened a card the user had closed; a MACHINE send must not revive it (a human's own Resume click carries by_user and may). + if hidden and not by_user and session.ended_by_user: return apply_context_window(session) session.closed_at = None self.sessions[session_id] = session else: raise ValueError(f"Session {session_id} not found") - # Every automatic resume arrives hidden; a human's Stop or close outranks all of them, and only the human's own (never hidden) next message lifts the hold. - if hidden and session.ended_by_user: + # Every automatic resume arrives hidden; a human's Stop or close outranks all of them. `hidden` only means "do not render a user bubble", so the Resume chip's own click is hidden too and used to be swallowed here, leaving the chip to reappear forever (Eric, live, 2026-08-21). Authorship is what this guard cares about, so it asks by_user. + if hidden and not by_user and session.ended_by_user: return - if not hidden and session.ended_by_user: + if session.ended_by_user and (not hidden or by_user): session.ended_by_user = False existing = self.tasks.get(session_id) diff --git a/backend/tests/test_user_stop_is_final.py b/backend/tests/test_user_stop_is_final.py index 73f5d052..68e1e75b 100644 --- a/backend/tests/test_user_stop_is_final.py +++ b/backend/tests/test_user_stop_is_final.py @@ -205,3 +205,36 @@ def test_a_standalone_browser_run_with_no_parent_proceeds(): """NEGATIVE CONTROL: a run that never had a parent is not an orphan; it must not self-cancel.""" assert p_child_entry_check(None) is False assert p_child_entry_check("") is False + + +# --- the human's own Resume click is not a machine send ------------------------------------------ + + +def test_the_resume_chip_lifts_the_hold_but_the_watchdog_still_cannot(): + """Live regression (Eric, 2026-08-21): clicking Resume on a user-stopped chat did nothing and the + amber chip came straight back, forever. The chip's send is `hidden` only so no user bubble + renders; this guard cares about AUTHORSHIP, so it must ask by_user, not hidden. Both directions + pinned here: the human's click runs a turn and clears the hold, a machine's identical hidden send + still does not.""" + started, real = p_spy_loop() + try: + # A machine send (the watchdog's resend) stays blocked: no turn, hold intact. + machine = p_live("machine") + machine.status = "stopped" + machine.ended_by_user = True + asyncio.run(agent_manager.send_message(machine.id, "Continue.", hidden=True)) + assert started == [], "a machine send must never revive a user-stopped chat" + assert machine.ended_by_user is True, "the hold must survive a machine send" + + # The human's Resume click: same hidden flag, but by_user, so it runs and lifts the hold. + human = p_live("human") + human.status = "stopped" + human.ended_by_user = True + asyncio.run(agent_manager.send_message(human.id, "Continue your previous response.", hidden=True, by_user=True)) + assert started == [human.id], "the user's own Resume click must actually run a turn" + assert human.ended_by_user is False, "the human lifted their own hold" + finally: + agent_manager.run_agent_loop = real + for s in list(agent_manager.sessions.values()): + if s.name in ("machine", "human"): + agent_manager.sessions.pop(s.id, None) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index b77f09aa..86d3b2b3 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1078,6 +1078,8 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose mode, model, hidden: true, + // Hidden so no user bubble renders, but a HUMAN clicked it: without this the backend's stopped-session guard swallows the send and the chip reappears forever. + byUser: true, })); }, [id, mode, model, dispatch]); diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 1314be4e..15146772 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -249,6 +249,8 @@ export interface SendMessagePayload { forcedTools?: string[]; attachedSkills?: Array<{ id: string; name: string; content: string }>; hidden?: boolean; + /** A human clicked for this (the Resume chip), even though it is hidden so no user bubble renders. */ + byUser?: boolean; selectedBrowserIds?: string[]; selectedAppIds?: string[]; selectedSettingIds?: string[]; @@ -260,7 +262,7 @@ function _genOptimisticId(): string { export const sendMessage = createAsyncThunk( 'agents/sendMessage', - async ({ sessionId, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds, selectedAppIds, selectedSettingIds }: SendMessagePayload, { dispatch }) => { + async ({ sessionId, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, hidden, byUser, selectedBrowserIds, selectedAppIds, selectedSettingIds }: SendMessagePayload, { dispatch }) => { // Mint client id and dispatch optimistic bubble before awaiting the network; id round-trips for echo dedupe. const clientMessageId = _genOptimisticId(); dispatch(addOptimisticMessage({ @@ -277,7 +279,7 @@ export const sendMessage = createAsyncThunk( const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, selected_setting_ids: selectedSettingIds, client_message_id: clientMessageId }), + body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, by_user: byUser ?? false, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, selected_setting_ids: selectedSettingIds, client_message_id: clientMessageId }), }); if (!res.ok) throw new Error(`send failed: ${res.status}`); } catch (err) {