"""Unit coverage for the extracted PostToolUse hook (tool_result_hook). The streaming harness mocks claude_agent_sdk.query, so it never fires the SDK's PostToolUse hooks; this pins the behavior directly: a tool result becomes a tool_result message, and view-builder writes surface build/console errors into the result. SpawnAgent (the sub-agent path) is pinned in test_spawn_agent.py.""" import pytest from unittest.mock import patch, AsyncMock, MagicMock from backend.apps.agents.core.models import AgentSession from backend.apps.agents.manager.streaming.HookContext import HookContext from backend.apps.agents.manager.streaming import post_tool_hook as tool_result_hook from backend.apps.agents.manager import view_builder_state def p_ctx(registry: dict) -> HookContext: session = AgentSession(name="t", model="sonnet", dashboard_id="d") registry[session.id] = session return HookContext( session=session, session_id=session.id, prompt="hi", builtin_perms={}, policy_defaults={}, sessions=registry, ) @pytest.mark.asyncio async def test_normal_tool_result_appends_message_and_continues(): registry: dict = {} ctx = p_ctx(registry) before = len(ctx.session.messages) with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send: out = await tool_result_hook.post_tool_hook( ctx, {"tool_name": "Read", "tool_response": "file body", "tool_input": {"file_path": "/x"}}, "tu1", None ) assert out == {"continue_": True} assert len(ctx.session.messages) == before + 1 msg = ctx.session.messages[-1] assert msg.role == "tool_result" assert "file body" in str(msg.content) send.assert_awaited() # the tool_result is broadcast to the UI @pytest.mark.asyncio async def test_view_builder_dep_install_broadcasts_app_deps_changed(): """An npm install in a view-builder session must tell the app card this turn changed deps (agent:app_deps_changed), so its turn-finish reload restarts Vite instead of soft-reloading a preview that can't see the new packages.""" registry: dict = {} ctx = p_ctx(registry) ctx.session.mode = "view-builder" # The broadcast gates on an attached preview runtime (not the mode), so agent-mode CreateApp builds get it too. fake_runtime_manager = MagicMock() fake_runtime_manager.get.return_value = object() with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send, \ patch("backend.apps.outputs.runtime.manager", fake_runtime_manager): await tool_result_hook.post_tool_hook( ctx, {"tool_name": "Bash", "tool_response": "added 3 packages", "tool_input": {"command": "npm install recharts"}}, "tu1", None ) view_builder_state.view_builder_dirty_sessions.discard(ctx.session_id) events = [c.args[1] for c in send.await_args_list] assert "agent:app_deps_changed" in events @pytest.mark.asyncio async def test_view_builder_plain_write_does_not_flag_deps_changed(): """A plain file edit must NOT escalate the reload; only dep changes do.""" registry: dict = {} ctx = p_ctx(registry) ctx.session.mode = "view-builder" with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send: await tool_result_hook.post_tool_hook( ctx, {"tool_name": "Write", "tool_response": "ok", "tool_input": {"file_path": "/ws/frontend/src/App.tsx", "content": "x"}}, "tu1", None ) events = [c.args[1] for c in send.await_args_list] assert "agent:app_deps_changed" not in events def p_app_manager(build_errors=(), console_errors=()) -> MagicMock: fake = MagicMock() fake.drain_errors_for_path.return_value = list(build_errors) fake.drain_frontend_errors_for_path.return_value = list(console_errors) return fake async def p_write(ctx, fake_manager, file_path="/ws/frontend/src/App.tsx") -> str: with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()), \ patch("backend.apps.outputs.runtime.manager", fake_manager): await tool_result_hook.post_tool_hook( ctx, {"tool_name": "Write", "tool_response": "ok", "tool_input": {"file_path": file_path, "content": "x"}}, "tu1", None ) view_builder_state.view_builder_dirty_sessions.discard(ctx.session_id) return str(ctx.session.messages[-1].content) @pytest.mark.asyncio async def test_view_builder_write_surfaces_build_errors_into_the_tool_result(): """Regression: this drain sat behind an `elif` on the view-builder reload branch, so a frontend write took that branch instead -- the App Builder was the one agent that never saw its own vite/babel/tsc errors after a write.""" ctx = p_ctx({}) ctx.session.mode = "view-builder" body = await p_write(ctx, p_app_manager(build_errors=["[plugin:vite] SyntaxError: Unexpected token"])) assert "Build server reported" in body assert "SyntaxError" in body @pytest.mark.asyncio async def test_write_surfaces_app_console_errors_as_a_separate_note(): ctx = p_ctx({}) ctx.session.mode = "view-builder" body = await p_write(ctx, p_app_manager(console_errors=["TypeError: x is not a function"])) assert "The app's console logged" in body assert "TypeError: x is not a function" in body @pytest.mark.asyncio async def test_createapp_write_from_another_mode_still_surfaces_errors(): """An agent in normal chat mode editing an app it made with CreateApp gets the same feedback.""" ctx = p_ctx({}) # default mode, not view-builder body = await p_write(ctx, p_app_manager(build_errors=["Traceback (most recent call last)"])) assert "Traceback" in body @pytest.mark.asyncio async def test_write_outside_any_app_workspace_adds_no_note(): ctx = p_ctx({}) body = await p_write(ctx, p_app_manager(), file_path="/some/other/repo/main.py") assert "Build server reported" not in body assert "The app's console logged" not in body