diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index e8e9554c..99026521 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -60,9 +60,9 @@ from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState from backend.apps.agents.manager.streaming.hook_context import HookContext from backend.apps.agents.manager.streaming import thinking as thinking_mod from backend.apps.agents.manager.streaming import tool_result_hook +from backend.apps.agents.manager.streaming import stop_hook as stop_hook_mod from backend.apps.agents.manager.permissions import gate_hooks from backend.apps.agents.manager.view_builder_state import ( - VIEW_BUILDER_RENDER_MAX_RETRIES, view_builder_render_retry_counts, view_builder_dirty_sessions, ) @@ -888,57 +888,7 @@ class AgentManager: del _stderr_buffer[:250] async def stop_hook(input_data, tool_use_id, context): - """End-of-turn render gate for App Builder sessions. Reads the - browser-reported render-state of the preview; if the app fails - to render, blocks with the error so the agent fixes it, up to - MAX_RETRIES then lets the stop through.""" - if session.mode != "view-builder": - return {} - if session.id not in view_builder_dirty_sessions: - return {} - from backend.apps.outputs.runtime import ( - manager as outputs_runtime_manager, - ) - if outputs_runtime_manager.get(session.id) is None: - return {} - state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id) - waited = 0.0 - while state is None and waited < 5.0: - await asyncio.sleep(0.25) - waited += 0.25 - state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id) - - if state != "error": - view_builder_render_retry_counts.pop(session.id, None) - view_builder_dirty_sessions.discard(session.id) - return {} - - attempts = view_builder_render_retry_counts.get(session.id, 0) - if attempts >= VIEW_BUILDER_RENDER_MAX_RETRIES: - logger.warning( - "view-builder preview still failing after %s attempts for session %s; allowing stop", - attempts, session.id, - ) - view_builder_render_retry_counts.pop(session.id, None) - view_builder_dirty_sessions.discard(session.id) - return {} - - view_builder_render_retry_counts[session.id] = attempts + 1 - logger.info( - "view-builder render block (attempt %s/%s) for session %s", - attempts + 1, VIEW_BUILDER_RENDER_MAX_RETRIES, session.id, - ) - trimmed = error_text[-3000:] if len(error_text) > 3000 else error_text - return { - "decision": "block", - "reason": ( - f"The preview failed to render (attempt {attempts + 1}/" - f"{VIEW_BUILDER_RENDER_MAX_RETRIES}):\n\n" - f"{trimmed}\n\n" - "Fix this so the app renders before finishing; the user " - "currently sees an error instead of the app." - ), - } + return await stop_hook_mod.stop_hook(hook_ctx, input_data, tool_use_id, context) options_kwargs = { "model": resolved_model, diff --git a/backend/apps/agents/manager/streaming/stop_hook.py b/backend/apps/agents/manager/streaming/stop_hook.py new file mode 100644 index 00000000..020dd3ae --- /dev/null +++ b/backend/apps/agents/manager/streaming/stop_hook.py @@ -0,0 +1,75 @@ +"""The SDK Stop hook: an end-of-turn render gate for App Builder (view-builder) sessions. +If the live preview failed to render, it blocks the stop with the error so the agent fixes it, +up to a retry cap, then lets the turn end. Operates on the HookContext; the dict returns are +the claude_agent_sdk Stop hook protocol, not internal state.""" + +import asyncio +import logging +from typing import Dict + +from typeguard import typechecked + +from backend.apps.agents.manager.streaming.hook_context import HookContext +from backend.apps.agents.manager.view_builder_state import ( + VIEW_BUILDER_RENDER_MAX_RETRIES, + view_builder_render_retry_counts, + view_builder_dirty_sessions, +) + +logger = logging.getLogger(__name__) + + +@typechecked +async def stop_hook(ctx: HookContext, input_data: dict, tool_use_id, context) -> Dict[str, object]: + """End-of-turn render gate for App Builder sessions. Reads the + browser-reported render-state of the preview; if the app fails + to render, blocks with the error so the agent fixes it, up to + MAX_RETRIES then lets the stop through.""" + session = ctx.session + if session.mode != "view-builder": + return {} + if session.id not in view_builder_dirty_sessions: + return {} + from backend.apps.outputs.runtime import ( + manager as outputs_runtime_manager, + ) + if outputs_runtime_manager.get(session.id) is None: + return {} + state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id) + waited = 0.0 + while state is None and waited < 5.0: + await asyncio.sleep(0.25) + waited += 0.25 + state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id) + + if state != "error": + view_builder_render_retry_counts.pop(session.id, None) + view_builder_dirty_sessions.discard(session.id) + return {} + + attempts = view_builder_render_retry_counts.get(session.id, 0) + if attempts >= VIEW_BUILDER_RENDER_MAX_RETRIES: + logger.warning( + "view-builder preview still failing after %s attempts for session %s; allowing stop", + attempts, session.id, + ) + view_builder_render_retry_counts.pop(session.id, None) + view_builder_dirty_sessions.discard(session.id) + return {} + + view_builder_render_retry_counts[session.id] = attempts + 1 + logger.info( + "view-builder render block (attempt %s/%s) for session %s", + attempts + 1, VIEW_BUILDER_RENDER_MAX_RETRIES, session.id, + ) + trimmed = error_text[-3000:] if len(error_text) > 3000 else error_text + return { + "decision": "block", + "reason": ( + f"The preview failed to render (attempt {attempts + 1}/" + f"{VIEW_BUILDER_RENDER_MAX_RETRIES}):\n\n" + f"{trimmed}\n\n" + "Fix this so the app renders before finishing; the user " + "currently sees an error instead of the app." + ), + } diff --git a/backend/tests/test_stop_hook.py b/backend/tests/test_stop_hook.py new file mode 100644 index 00000000..b05f0afe --- /dev/null +++ b/backend/tests/test_stop_hook.py @@ -0,0 +1,50 @@ +"""Unit coverage for the extracted Stop hook (the App Builder render gate). Not exercised by +the streaming harness (Stop fires from SDK internals), so pin it directly: the gate is inert +off view-builder mode / when not dirty, and blocks the stop when the preview errors under cap.""" + +import pytest +from unittest.mock import patch, MagicMock + +from backend.apps.agents.core.models import AgentSession +from backend.apps.agents.manager.streaming.hook_context import HookContext +from backend.apps.agents.manager.streaming import stop_hook as stop_hook_mod +from backend.apps.agents.manager import view_builder_state + + +def _ctx(mode: str) -> HookContext: + session = AgentSession(name="t", model="sonnet", dashboard_id="d", mode=mode) + return HookContext( + session=session, session_id=session.id, prompt="hi", + builtin_perms={}, policy_defaults={}, sessions={}, + ) + + +@pytest.mark.asyncio +async def test_stop_hook_inert_when_not_view_builder(): + ctx = _ctx("agent") + assert await stop_hook_mod.stop_hook(ctx, {}, None, None) == {} + + +@pytest.mark.asyncio +async def test_stop_hook_inert_when_not_dirty(): + ctx = _ctx("view-builder") # dirty set is empty -> nothing to gate + assert await stop_hook_mod.stop_hook(ctx, {}, None, None) == {} + + +@pytest.mark.asyncio +async def test_stop_hook_blocks_on_render_error_under_cap(): + ctx = _ctx("view-builder") + sid = ctx.session.id + view_builder_state.view_builder_dirty_sessions.add(sid) + fake_runtime = MagicMock() + fake_runtime.get.return_value = object() # workspace exists + fake_runtime.get_render_state_for_workspace.return_value = ("error", "boom traceback") + try: + with patch("backend.apps.outputs.runtime.manager", fake_runtime): + out = await stop_hook_mod.stop_hook(ctx, {}, None, None) + assert out["decision"] == "block" + assert "failed to render" in out["reason"] + assert "boom traceback" in out["reason"] + finally: + view_builder_state.view_builder_dirty_sessions.discard(sid) + view_builder_state.view_builder_render_retry_counts.pop(sid, None)