[eric] agents: the Resume chip is a human, so a stopped chat honours it (ENG-384)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018foyDoK19jjbYdudfzQVkZ
This commit is contained in:
ciregenz
2026-08-21 08:07:55 -07:00
co-authored by Claude Opus 5
parent f3e7f7b1af
commit da18c0a5ef
5 changed files with 46 additions and 7 deletions
+1
View File
@@ -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"),
+6 -5
View File
@@ -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)
+33
View File
@@ -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)
@@ -1078,6 +1078,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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]);
+4 -2
View File
@@ -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) {