mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
135 lines
6.0 KiB
Python
135 lines
6.0 KiB
Python
"""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
|