431 lines
15 KiB
Python
431 lines
15 KiB
Python
"""Tests for the query engine."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from openharness.api.client import ApiMessageCompleteEvent, ApiRetryEvent, ApiTextDeltaEvent
|
|
from openharness.api.usage import UsageSnapshot
|
|
from openharness.config.settings import PermissionSettings
|
|
from openharness.engine.messages import ConversationMessage, TextBlock, ToolUseBlock
|
|
from openharness.engine.query_engine import QueryEngine
|
|
from openharness.engine.stream_events import (
|
|
AssistantTextDelta,
|
|
AssistantTurnComplete,
|
|
StatusEvent,
|
|
ToolExecutionCompleted,
|
|
ToolExecutionStarted,
|
|
)
|
|
from openharness.permissions import PermissionChecker, PermissionMode
|
|
from openharness.tools import create_default_tool_registry
|
|
from openharness.hooks import HookExecutionContext, HookExecutor, HookEvent
|
|
from openharness.hooks.loader import HookRegistry
|
|
from openharness.hooks.schemas import PromptHookDefinition
|
|
|
|
|
|
@dataclass
|
|
class _FakeResponse:
|
|
message: ConversationMessage
|
|
usage: UsageSnapshot
|
|
|
|
|
|
class FakeApiClient:
|
|
"""Deterministic streaming client used by query tests."""
|
|
|
|
def __init__(self, responses: list[_FakeResponse]) -> None:
|
|
self._responses = list(responses)
|
|
|
|
async def stream_message(self, request):
|
|
del request
|
|
response = self._responses.pop(0)
|
|
for block in response.message.content:
|
|
if isinstance(block, TextBlock) and block.text:
|
|
yield ApiTextDeltaEvent(text=block.text)
|
|
yield ApiMessageCompleteEvent(
|
|
message=response.message,
|
|
usage=response.usage,
|
|
stop_reason=None,
|
|
)
|
|
|
|
|
|
class StaticApiClient:
|
|
"""Fake client that always returns one fixed assistant message."""
|
|
|
|
def __init__(self, text: str) -> None:
|
|
self._text = text
|
|
|
|
async def stream_message(self, request):
|
|
del request
|
|
yield ApiMessageCompleteEvent(
|
|
message=ConversationMessage(role="assistant", content=[TextBlock(text=self._text)]),
|
|
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
|
|
stop_reason=None,
|
|
)
|
|
|
|
|
|
class RetryThenSuccessApiClient:
|
|
async def stream_message(self, request):
|
|
del request
|
|
yield ApiRetryEvent(message="rate limited", attempt=1, max_attempts=4, delay_seconds=1.5)
|
|
yield ApiMessageCompleteEvent(
|
|
message=ConversationMessage(role="assistant", content=[TextBlock(text="after retry")]),
|
|
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
|
|
stop_reason=None,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_engine_plain_text_reply(tmp_path: Path):
|
|
engine = QueryEngine(
|
|
api_client=FakeApiClient(
|
|
[
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[TextBlock(text="Hello from the model.")],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=10, output_tokens=5),
|
|
)
|
|
]
|
|
),
|
|
tool_registry=create_default_tool_registry(),
|
|
permission_checker=PermissionChecker(PermissionSettings()),
|
|
cwd=tmp_path,
|
|
model="claude-test",
|
|
system_prompt="system",
|
|
)
|
|
|
|
events = [event async for event in engine.submit_message("hello")]
|
|
|
|
assert isinstance(events[0], AssistantTextDelta)
|
|
assert events[0].text == "Hello from the model."
|
|
assert isinstance(events[-1], AssistantTurnComplete)
|
|
assert engine.total_usage.input_tokens == 10
|
|
assert engine.total_usage.output_tokens == 5
|
|
assert len(engine.messages) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_engine_executes_tool_calls(tmp_path: Path):
|
|
sample = tmp_path / "hello.txt"
|
|
sample.write_text("alpha\nbeta\n", encoding="utf-8")
|
|
|
|
engine = QueryEngine(
|
|
api_client=FakeApiClient(
|
|
[
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[
|
|
TextBlock(text="I will inspect the file."),
|
|
ToolUseBlock(
|
|
id="toolu_123",
|
|
name="read_file",
|
|
input={"path": str(sample), "offset": 0, "limit": 2},
|
|
),
|
|
],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=4, output_tokens=3),
|
|
),
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[TextBlock(text="The file contains alpha and beta.")],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=8, output_tokens=6),
|
|
),
|
|
]
|
|
),
|
|
tool_registry=create_default_tool_registry(),
|
|
permission_checker=PermissionChecker(PermissionSettings()),
|
|
cwd=tmp_path,
|
|
model="claude-test",
|
|
system_prompt="system",
|
|
)
|
|
|
|
events = [event async for event in engine.submit_message("read the file")]
|
|
|
|
assert any(isinstance(event, ToolExecutionStarted) for event in events)
|
|
tool_results = [event for event in events if isinstance(event, ToolExecutionCompleted)]
|
|
assert len(tool_results) == 1
|
|
assert "alpha" in tool_results[0].output
|
|
assert isinstance(events[-1], AssistantTurnComplete)
|
|
assert "alpha and beta" in events[-1].message.text
|
|
assert len(engine.messages) == 4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_engine_allows_unbounded_turns_when_max_turns_is_none(tmp_path: Path):
|
|
sample = tmp_path / "hello.txt"
|
|
sample.write_text("alpha\nbeta\n", encoding="utf-8")
|
|
|
|
engine = QueryEngine(
|
|
api_client=FakeApiClient(
|
|
[
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[
|
|
TextBlock(text="I will inspect the file."),
|
|
ToolUseBlock(
|
|
id="toolu_123",
|
|
name="read_file",
|
|
input={"path": str(sample), "offset": 0, "limit": 2},
|
|
),
|
|
],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=4, output_tokens=3),
|
|
),
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[TextBlock(text="The file contains alpha and beta.")],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=8, output_tokens=6),
|
|
),
|
|
]
|
|
),
|
|
tool_registry=create_default_tool_registry(),
|
|
permission_checker=PermissionChecker(PermissionSettings()),
|
|
cwd=tmp_path,
|
|
model="claude-test",
|
|
system_prompt="system",
|
|
max_turns=None,
|
|
)
|
|
|
|
events = [event async for event in engine.submit_message("read the file")]
|
|
|
|
assert isinstance(events[-1], AssistantTurnComplete)
|
|
assert "alpha and beta" in events[-1].message.text
|
|
assert engine.max_turns is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_engine_surfaces_retry_status_events(tmp_path: Path):
|
|
engine = QueryEngine(
|
|
api_client=RetryThenSuccessApiClient(),
|
|
tool_registry=create_default_tool_registry(),
|
|
permission_checker=PermissionChecker(PermissionSettings()),
|
|
cwd=tmp_path,
|
|
model="claude-test",
|
|
system_prompt="system",
|
|
)
|
|
|
|
events = [event async for event in engine.submit_message("hello")]
|
|
|
|
assert any(isinstance(event, StatusEvent) and "retrying in 1.5s" in event.message for event in events)
|
|
assert isinstance(events[-1], AssistantTurnComplete)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_engine_respects_pre_tool_hook_blocks(tmp_path: Path):
|
|
sample = tmp_path / "hello.txt"
|
|
sample.write_text("alpha\n", encoding="utf-8")
|
|
registry = HookRegistry()
|
|
registry.register(
|
|
HookEvent.PRE_TOOL_USE,
|
|
PromptHookDefinition(prompt="reject", matcher="read_file"),
|
|
)
|
|
|
|
engine = QueryEngine(
|
|
api_client=FakeApiClient(
|
|
[
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[
|
|
ToolUseBlock(
|
|
id="toolu_999",
|
|
name="read_file",
|
|
input={"path": str(sample)},
|
|
)
|
|
],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
|
|
),
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[TextBlock(text="blocked")],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
|
|
),
|
|
]
|
|
),
|
|
tool_registry=create_default_tool_registry(),
|
|
permission_checker=PermissionChecker(PermissionSettings()),
|
|
cwd=tmp_path,
|
|
model="claude-test",
|
|
system_prompt="system",
|
|
hook_executor=HookExecutor(
|
|
registry,
|
|
HookExecutionContext(
|
|
cwd=tmp_path,
|
|
api_client=StaticApiClient('{"ok": false, "reason": "no reading"}'),
|
|
default_model="claude-test",
|
|
),
|
|
),
|
|
)
|
|
|
|
events = [event async for event in engine.submit_message("read file")]
|
|
|
|
tool_results = [event for event in events if isinstance(event, ToolExecutionCompleted)]
|
|
assert tool_results
|
|
assert tool_results[0].is_error is True
|
|
assert "no reading" in tool_results[0].output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_engine_executes_ask_user_tool(tmp_path: Path):
|
|
async def _answer(question: str) -> str:
|
|
assert question == "Which color?"
|
|
return "green"
|
|
|
|
engine = QueryEngine(
|
|
api_client=FakeApiClient(
|
|
[
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[
|
|
ToolUseBlock(
|
|
id="toolu_ask",
|
|
name="ask_user_question",
|
|
input={"question": "Which color?"},
|
|
),
|
|
],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
|
|
),
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[TextBlock(text="Picked green.")],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
|
|
),
|
|
]
|
|
),
|
|
tool_registry=create_default_tool_registry(),
|
|
permission_checker=PermissionChecker(PermissionSettings()),
|
|
cwd=tmp_path,
|
|
model="claude-test",
|
|
system_prompt="system",
|
|
ask_user_prompt=_answer,
|
|
)
|
|
|
|
events = [event async for event in engine.submit_message("pick a color")]
|
|
|
|
tool_results = [event for event in events if isinstance(event, ToolExecutionCompleted)]
|
|
assert tool_results
|
|
assert tool_results[0].output == "green"
|
|
assert isinstance(events[-1], AssistantTurnComplete)
|
|
assert events[-1].message.text == "Picked green."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_engine_applies_path_rules_to_relative_read_file_targets(tmp_path: Path):
|
|
blocked_dir = tmp_path / "blocked"
|
|
blocked_dir.mkdir()
|
|
secret = blocked_dir / "secret.txt"
|
|
secret.write_text("top-secret\n", encoding="utf-8")
|
|
|
|
engine = QueryEngine(
|
|
api_client=FakeApiClient(
|
|
[
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[
|
|
ToolUseBlock(
|
|
id="toolu_blocked_read",
|
|
name="read_file",
|
|
input={"path": "blocked/secret.txt", "offset": 0, "limit": 1},
|
|
)
|
|
],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
|
|
),
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[TextBlock(text="blocked")],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
|
|
),
|
|
]
|
|
),
|
|
tool_registry=create_default_tool_registry(),
|
|
permission_checker=PermissionChecker(
|
|
PermissionSettings(
|
|
mode=PermissionMode.DEFAULT,
|
|
path_rules=[{"pattern": str((blocked_dir / "*").resolve()), "allow": False}],
|
|
)
|
|
),
|
|
cwd=tmp_path,
|
|
model="claude-test",
|
|
system_prompt="system",
|
|
)
|
|
|
|
events = [event async for event in engine.submit_message("read blocked file")]
|
|
|
|
tool_results = [event for event in events if isinstance(event, ToolExecutionCompleted)]
|
|
assert tool_results
|
|
assert tool_results[0].is_error is True
|
|
assert "matches deny rule" in tool_results[0].output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_engine_applies_path_rules_to_write_file_targets_in_full_auto(tmp_path: Path):
|
|
blocked_dir = tmp_path / "blocked"
|
|
blocked_dir.mkdir()
|
|
target = blocked_dir / "output.txt"
|
|
|
|
engine = QueryEngine(
|
|
api_client=FakeApiClient(
|
|
[
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[
|
|
ToolUseBlock(
|
|
id="toolu_blocked_write",
|
|
name="write_file",
|
|
input={"path": "blocked/output.txt", "content": "poc"},
|
|
)
|
|
],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
|
|
),
|
|
_FakeResponse(
|
|
message=ConversationMessage(
|
|
role="assistant",
|
|
content=[TextBlock(text="blocked")],
|
|
),
|
|
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
|
|
),
|
|
]
|
|
),
|
|
tool_registry=create_default_tool_registry(),
|
|
permission_checker=PermissionChecker(
|
|
PermissionSettings(
|
|
mode=PermissionMode.FULL_AUTO,
|
|
path_rules=[{"pattern": str((blocked_dir / "*").resolve()), "allow": False}],
|
|
)
|
|
),
|
|
cwd=tmp_path,
|
|
model="claude-test",
|
|
system_prompt="system",
|
|
)
|
|
|
|
events = [event async for event in engine.submit_message("write blocked file")]
|
|
|
|
tool_results = [event for event in events if isinstance(event, ToolExecutionCompleted)]
|
|
assert tool_results
|
|
assert tool_results[0].is_error is True
|
|
assert "matches deny rule" in tool_results[0].output
|
|
assert target.exists() is False
|