mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
* feat(harness): add litellm/__init__.py * feat(harness): add litellm/constants.py * feat(harness): add litellm/harness/__init__.py * feat(harness): add litellm/harness/adapters/__init__.py * feat(harness): add litellm/harness/adapters/base.py * feat(harness): add litellm/harness/adapters/claude_code.py * feat(harness): add litellm/harness/adapters/codex.py * feat(harness): add litellm/harness/adapters/opencode.py * feat(harness): add litellm/harness/endpoint.py * feat(harness): add litellm/harness/errors.py * feat(harness): add litellm/harness/options.py * feat(harness): add litellm/harness/runtime.py * feat(harness): add litellm/harness/sandbox/__init__.py * feat(harness): add litellm/harness/sandbox/base.py * feat(harness): add litellm/harness/sandbox/docker.py * feat(harness): add litellm/harness/sandbox/local.py * feat(harness): add litellm/harness/sandbox/snapshot.py * feat(harness): add litellm/harness/sync.py * feat(harness): add litellm/harness/types.py * feat(harness): add litellm/sandbox/__init__.py * feat(harness): add README.md * feat(harness): add tests/harness_e2e/__init__.py * feat(harness): add tests/harness_e2e/conftest.py * feat(harness): add tests/harness_e2e/test_harness_e2e.py * feat(harness): add tests/test_litellm/harness/__init__.py * feat(harness): add tests/test_litellm/harness/adapters/__init__.py * feat(harness): add tests/test_litellm/harness/adapters/fixtures/claude_code/api_error.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/claude_code/max_turns.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/claude_code/resume_turn.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/claude_code/structured_output.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/claude_code/success_tools.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/codex/reasoning.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/codex/structured_output.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/codex/turn_failed.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/codex/turn1_bash.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/codex/turn2_resume_apply_patch.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/opencode/api_error.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/opencode/endpoint_requests.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/opencode/readonly_denied_bash.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/opencode/turn1_write_read.jsonl * feat(harness): add tests/test_litellm/harness/adapters/fixtures/opencode/turn2_session_skill.jsonl * feat(harness): add tests/test_litellm/harness/adapters/test_claude_code.py * feat(harness): add tests/test_litellm/harness/adapters/test_codex.py * feat(harness): add tests/test_litellm/harness/adapters/test_opencode.py * feat(harness): add tests/test_litellm/harness/core_fakes.py * feat(harness): add tests/test_litellm/harness/sandbox/__init__.py * feat(harness): add tests/test_litellm/harness/sandbox/test_docker.py * feat(harness): add tests/test_litellm/harness/sandbox/test_local.py * feat(harness): add tests/test_litellm/harness/sandbox/test_snapshot.py * feat(harness): add tests/test_litellm/harness/test_endpoint.py * feat(harness): add tests/test_litellm/harness/test_init.py * feat(harness): add tests/test_litellm/harness/test_runtime.py * feat(harness): add tests/test_litellm/harness/test_sync.py * feat(harness): add tests/test_litellm/harness/test_types.py * test(harness): use word recall in stream e2e test * refactor(harness): update litellm/__init__.py * refactor(harness): update litellm/constants.py * refactor(harness): update litellm/harness/__init__.py * refactor(harness): remove litellm/harness/adapters/__init__.py * refactor(harness): update litellm/harness/context.py * refactor(harness): update litellm/harness/endpoint.py * refactor(harness): update litellm/harness/handlers/__init__.py * refactor(harness): update litellm/harness/handlers/base.py * refactor(harness): update litellm/harness/handlers/cli_handler.py * refactor(harness): update litellm/harness/handlers/deepagents_handler.py * refactor(harness): update litellm/harness/runtime.py * refactor(harness): update litellm/harness/sandbox/docker.py * refactor(harness): update litellm/harness/sandbox/local.py * refactor(harness): update litellm/harness/sync.py * refactor(harness): update litellm/harness/types.py * refactor(harness): update litellm/llms/base_llm/harness/__init__.py * refactor(harness): update litellm/llms/base_llm/harness/transformation.py * refactor(harness): update litellm/llms/base_llm/harness/utils.py * refactor(harness): update litellm/llms/claude_code/__init__.py * refactor(harness): update litellm/llms/claude_code/harness/__init__.py * refactor(harness): update litellm/llms/claude_code/harness/transformation.py * refactor(harness): update litellm/llms/codex/__init__.py * refactor(harness): update litellm/llms/codex/harness/__init__.py * refactor(harness): update litellm/llms/codex/harness/transformation.py * refactor(harness): update litellm/llms/deepagents/__init__.py * refactor(harness): update litellm/llms/deepagents/harness/__init__.py * refactor(harness): update litellm/llms/deepagents/harness/sandbox_backend.py * refactor(harness): update litellm/llms/deepagents/harness/transformation.py * refactor(harness): update litellm/llms/opencode/__init__.py * refactor(harness): update litellm/llms/opencode/harness/__init__.py * refactor(harness): update litellm/llms/opencode/harness/transformation.py * refactor(harness): update litellm/utils.py * refactor(harness): update README.md * refactor(harness): update tests/harness_e2e/conftest.py * refactor(harness): update tests/harness_e2e/test_harness_e2e.py * refactor(harness): remove tests/test_litellm/harness/__init__.py * refactor(harness): remove tests/test_litellm/harness/adapters/__init__.py * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/claude_code/api_error.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/claude_code/max_turns.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/claude_code/resume_turn.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/claude_code/structured_output.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/claude_code/success_tools.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/codex/reasoning.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/codex/structured_output.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/codex/turn_failed.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/codex/turn1_bash.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/codex/turn2_resume_apply_patch.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/opencode/api_error.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/opencode/endpoint_requests.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/opencode/readonly_denied_bash.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/opencode/turn1_write_read.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/fixtures/opencode/turn2_session_skill.jsonl * refactor(harness): remove tests/test_litellm/harness/adapters/test_claude_code.py * refactor(harness): remove tests/test_litellm/harness/adapters/test_codex.py * refactor(harness): remove tests/test_litellm/harness/adapters/test_opencode.py * refactor(harness): remove tests/test_litellm/harness/core_fakes.py * refactor(harness): remove tests/test_litellm/harness/sandbox/__init__.py * refactor(harness): remove tests/test_litellm/harness/sandbox/test_docker.py * refactor(harness): remove tests/test_litellm/harness/sandbox/test_local.py * refactor(harness): remove tests/test_litellm/harness/sandbox/test_snapshot.py * refactor(harness): remove tests/test_litellm/harness/test_endpoint.py * refactor(harness): remove tests/test_litellm/harness/test_init.py * refactor(harness): remove tests/test_litellm/harness/test_runtime.py * refactor(harness): remove tests/test_litellm/harness/test_sync.py * refactor(harness): remove tests/test_litellm/harness/test_types.py * refactor(harness): update tests/unit/harness/__init__.py * refactor(harness): update tests/unit/harness/core_fakes.py * refactor(harness): update tests/unit/harness/handlers/__init__.py * refactor(harness): update tests/unit/harness/handlers/test_deepagents_handler.py * refactor(harness): update tests/unit/harness/sandbox/__init__.py * refactor(harness): update tests/unit/harness/sandbox/test_docker.py * refactor(harness): update tests/unit/harness/sandbox/test_local.py * refactor(harness): update tests/unit/harness/sandbox/test_snapshot.py * refactor(harness): update tests/unit/harness/test_endpoint.py * refactor(harness): update tests/unit/harness/test_init.py * refactor(harness): update tests/unit/harness/test_runtime.py * refactor(harness): update tests/unit/harness/test_sync.py * refactor(harness): update tests/unit/harness/test_types.py * refactor(harness): update tests/unit/llms/claude_code/__init__.py * refactor(harness): update tests/unit/llms/claude_code/harness/__init__.py * refactor(harness): update tests/unit/llms/claude_code/harness/fixtures/api_error.jsonl * refactor(harness): update tests/unit/llms/claude_code/harness/fixtures/max_turns.jsonl * refactor(harness): update tests/unit/llms/claude_code/harness/fixtures/resume_turn.jsonl * refactor(harness): update tests/unit/llms/claude_code/harness/fixtures/structured_output.jsonl * refactor(harness): update tests/unit/llms/claude_code/harness/fixtures/success_tools.jsonl * refactor(harness): update tests/unit/llms/claude_code/harness/test_transformation.py * refactor(harness): update tests/unit/llms/codex/__init__.py * refactor(harness): update tests/unit/llms/codex/harness/__init__.py * refactor(harness): update tests/unit/llms/codex/harness/fixtures/reasoning.jsonl * refactor(harness): update tests/unit/llms/codex/harness/fixtures/structured_output.jsonl * refactor(harness): update tests/unit/llms/codex/harness/fixtures/turn1_bash.jsonl * refactor(harness): update tests/unit/llms/codex/harness/fixtures/turn2_resume_apply_patch.jsonl * refactor(harness): update tests/unit/llms/codex/harness/fixtures/turn_failed.jsonl * refactor(harness): update tests/unit/llms/codex/harness/test_transformation.py * refactor(harness): update tests/unit/llms/deepagents/__init__.py * refactor(harness): update tests/unit/llms/deepagents/harness/__init__.py * refactor(harness): update tests/unit/llms/deepagents/harness/test_transformation.py * refactor(harness): update tests/unit/llms/opencode/__init__.py * refactor(harness): update tests/unit/llms/opencode/harness/__init__.py * refactor(harness): update tests/unit/llms/opencode/harness/fixtures/api_error.jsonl * refactor(harness): update tests/unit/llms/opencode/harness/fixtures/endpoint_requests.jsonl * refactor(harness): update tests/unit/llms/opencode/harness/fixtures/readonly_denied_bash.jsonl * refactor(harness): update tests/unit/llms/opencode/harness/fixtures/turn1_write_read.jsonl * refactor(harness): update tests/unit/llms/opencode/harness/fixtures/turn2_session_skill.jsonl * refactor(harness): update tests/unit/llms/opencode/harness/test_transformation.py * ci: allowlist tests/harness_e2e, which needs live runtimes and a gateway * fix(harness): bound the turn event queue * fix(harness): bound the turn event queue with backpressure * style: sort imports in utils * ci: exclude agent-harness config folders from provider docs check * refactor(harness): update tests/harness_e2e/conftest.py * refactor(harness): update tests/harness_e2e/test_harness_e2e.py * refactor(harness): update tests/unit/harness/core_fakes.py * refactor(harness): update tests/unit/harness/handlers/test_deepagents_handler.py * refactor(harness): update tests/unit/harness/test_runtime.py * refactor(harness): update tests/unit/harness/test_sync.py * refactor(harness): update tests/unit/llms/deepagents/harness/test_transformation.py * refactor(harness): update tests/unit/llms/opencode/harness/test_transformation.py * refactor(harness): update litellm/harness/endpoint.py * refactor(harness): update litellm/harness/handlers/deepagents_handler.py * refactor(harness): update litellm/harness/options.py * refactor(harness): update litellm/harness/runtime.py * refactor(harness): update litellm/harness/sandbox/snapshot.py * refactor(harness): update litellm/llms/base_llm/harness/transformation.py * refactor(harness): update litellm/llms/base_llm/harness/utils.py * refactor(harness): update litellm/llms/claude_code/harness/transformation.py * refactor(harness): update litellm/llms/codex/harness/transformation.py * refactor(harness): update litellm/llms/deepagents/harness/sandbox_backend.py * refactor(harness): update litellm/llms/deepagents/harness/transformation.py * refactor(harness): update litellm/llms/opencode/harness/transformation.py * refactor(harness): update litellm/sandbox/__init__.py * refactor(harness): update tests/unit/llms/claude_code/harness/test_transformation.py * refactor(harness): update tests/unit/llms/deepagents/harness/test_sandbox_backend_symlinks.py * refactor(harness): update tests/unit/llms/opencode/harness/test_transformation.py * refactor(harness): update litellm/llms/base_llm/harness/utils.py * refactor(harness): update litellm/llms/codex/harness/transformation.py * refactor(harness): update tests/code_coverage_tests/recursive_detector.py * refactor(harness): update tests/unit/llms/base_llm/harness/__init__.py * refactor(harness): update tests/unit/llms/claude_code/harness/fixtures/__init__.py * refactor(harness): update tests/unit/llms/codex/harness/fixtures/__init__.py * refactor(harness): update tests/unit/llms/opencode/harness/fixtures/__init__.py * refactor(harness): update litellm/harness/__init__.py * refactor(harness): update litellm/harness/context.py * refactor(harness): update litellm/harness/endpoint.py * refactor(harness): update litellm/harness/handlers/__init__.py * refactor(harness): update litellm/harness/handlers/base.py * refactor(harness): update litellm/harness/handlers/cli_handler.py * refactor(harness): update litellm/harness/handlers/deepagents_handler.py * refactor(harness): update litellm/harness/runtime.py * refactor(harness): update litellm/harness/sandbox/__init__.py * refactor(harness): update litellm/harness/sandbox/base.py * refactor(harness): update litellm/harness/sandbox/docker.py * refactor(harness): update litellm/harness/sandbox/local.py * refactor(harness): update litellm/harness/sandbox/snapshot.py * refactor(harness): update litellm/harness/sync.py * refactor(harness): update litellm/harness/types.py * refactor(harness): update litellm/llms/base_llm/harness/transformation.py * refactor(harness): update litellm/llms/base_llm/harness/utils.py * refactor(harness): update litellm/llms/claude_code/harness/transformation.py * refactor(harness): update litellm/llms/codex/harness/transformation.py * refactor(harness): update litellm/llms/deepagents/harness/sandbox_backend.py * refactor(harness): update litellm/llms/deepagents/harness/transformation.py * refactor(harness): update litellm/llms/opencode/harness/transformation.py * refactor(harness): update litellm/types/llms/custom_http.py * refactor(harness): update tests/unit/harness/test_endpoint.py * refactor(harness): update litellm/harness/runtime.py * refactor(harness): update litellm/llms/deepagents/harness/sandbox_backend.py * refactor(harness): update tests/unit/harness/test_init.py * refactor(harness): update tests/unit/harness/test_runtime.py * refactor(harness): update tests/unit/llms/deepagents/harness/test_sandbox_backend_symlinks.py
621 lines
22 KiB
Python
621 lines
22 KiB
Python
"""Tests for litellm/harness/runtime.py using a fake adapter, sandbox and endpoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from collections.abc import AsyncIterator
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
|
|
from litellm.harness import runtime
|
|
from litellm.harness.context import SessionContext
|
|
from litellm.harness.errors import (
|
|
CapabilityUnsupported,
|
|
HarnessInstallFailed,
|
|
OptionsMismatch,
|
|
OutputInvalid,
|
|
SessionClosed,
|
|
StateIncompatible,
|
|
)
|
|
from litellm.harness.options import CodexOptions
|
|
from litellm.harness.types import (
|
|
Approval,
|
|
Done,
|
|
Event,
|
|
FileChange,
|
|
Harness,
|
|
State,
|
|
Text,
|
|
ToolCall,
|
|
)
|
|
from tests.unit.harness.core_fakes import (
|
|
NARROW_CAPS,
|
|
FakeAdapter,
|
|
FakeEndpoint,
|
|
FakeSandbox,
|
|
install_adapter,
|
|
script_approval,
|
|
wait_forever,
|
|
)
|
|
|
|
|
|
class Answer(BaseModel):
|
|
value: int
|
|
|
|
|
|
@pytest.fixture
|
|
def sandbox(tmp_path) -> FakeSandbox:
|
|
return FakeSandbox(str(tmp_path))
|
|
|
|
|
|
async def _collect(stream) -> list[Event]:
|
|
return [event async for event in stream]
|
|
|
|
|
|
# -- validation ---------------------------------------------------------------
|
|
|
|
|
|
async def test_string_harness_raises_type_error_with_hint(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch)
|
|
with pytest.raises(TypeError, match=r"Harness\.CODEX"):
|
|
await runtime.aagent("codex", "hi", sandbox=sandbox) # type: ignore[arg-type]
|
|
|
|
|
|
async def test_options_mismatch(monkeypatch, sandbox):
|
|
adapter_cls = install_adapter(monkeypatch)
|
|
with pytest.raises(OptionsMismatch, match="CodexOptions"):
|
|
await runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, options=CodexOptions()
|
|
)
|
|
assert adapter_cls.instances == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kwargs",
|
|
[
|
|
{"permissions": "edit"},
|
|
{"output": Answer},
|
|
{"tools": [print]},
|
|
{"disable_tools": ["bash"]},
|
|
{"permissions": "ask", "on_approval": lambda a: True},
|
|
],
|
|
)
|
|
async def test_capability_errors_before_start(monkeypatch, sandbox, kwargs):
|
|
adapter_cls = install_adapter(monkeypatch, caps=NARROW_CAPS)
|
|
with pytest.raises(CapabilityUnsupported):
|
|
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, **kwargs)
|
|
assert all("start" not in a.calls for a in adapter_cls.instances)
|
|
assert FakeEndpoint.instances == []
|
|
|
|
|
|
async def test_skills_capability_error_before_start(monkeypatch, sandbox, tmp_path):
|
|
skill = tmp_path / "skill"
|
|
skill.mkdir()
|
|
(skill / "SKILL.md").write_text("# s")
|
|
adapter_cls = install_adapter(monkeypatch, caps=NARROW_CAPS)
|
|
with pytest.raises(CapabilityUnsupported, match="skills"):
|
|
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, skills=[skill])
|
|
assert adapter_cls.instances == []
|
|
|
|
|
|
async def test_skill_folder_without_skill_md_rejected(monkeypatch, sandbox, tmp_path):
|
|
install_adapter(monkeypatch)
|
|
with pytest.raises(ValueError, match=r"SKILL\.md"):
|
|
await runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, skills=[tmp_path]
|
|
)
|
|
|
|
|
|
async def test_ask_without_handler_only_allowed_for_stream(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch)
|
|
with pytest.raises(ValueError, match="on_approval"):
|
|
await runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="ask"
|
|
)
|
|
stream = runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="ask", stream=True
|
|
)
|
|
events = await _collect(stream)
|
|
assert isinstance(events[-1], Done)
|
|
|
|
|
|
async def test_invalid_permissions_value(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch)
|
|
with pytest.raises(ValueError, match="permissions"):
|
|
await runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="yolo" # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
# -- gateway routing (litellm_proxy/ prefix) ---------------------------------
|
|
|
|
|
|
def test_litellm_proxy_prefix_routes_through_gateway_env(monkeypatch):
|
|
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gw.example.com/")
|
|
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
|
|
model, gateway = runtime.resolve_model_route("litellm_proxy/coder", None, None)
|
|
assert model == "coder"
|
|
assert gateway == runtime.GatewayTarget(
|
|
api_base="https://gw.example.com", api_key="sk-test"
|
|
)
|
|
|
|
|
|
def test_litellm_proxy_call_args_win_over_env(monkeypatch):
|
|
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://env.example.com")
|
|
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-env")
|
|
_, gateway = runtime.resolve_model_route(
|
|
"litellm_proxy/coder", "sk-arg", "https://arg.example.com"
|
|
)
|
|
assert gateway == runtime.GatewayTarget(
|
|
api_base="https://arg.example.com", api_key="sk-arg"
|
|
)
|
|
|
|
|
|
def test_litellm_proxy_without_base_raises(monkeypatch):
|
|
monkeypatch.delenv("LITELLM_PROXY_API_BASE", raising=False)
|
|
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
|
|
with pytest.raises(ValueError, match="LITELLM_PROXY_API_BASE"):
|
|
runtime.resolve_model_route("litellm_proxy/coder", None, None)
|
|
|
|
|
|
def test_litellm_proxy_without_key_raises(monkeypatch):
|
|
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gw.example.com")
|
|
monkeypatch.setenv("LITELLM_PROXY_API_KEY", " ")
|
|
with pytest.raises(ValueError, match="LITELLM_PROXY_API_KEY"):
|
|
runtime.resolve_model_route("litellm_proxy/coder", None, None)
|
|
|
|
|
|
def test_plain_model_is_sdk_mode_even_with_gateway_env(monkeypatch):
|
|
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gw.example.com")
|
|
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
|
|
assert runtime.resolve_model_route("anthropic/claude-sonnet-4-5", None, None) == (
|
|
"anthropic/claude-sonnet-4-5",
|
|
None,
|
|
)
|
|
|
|
|
|
def test_use_litellm_proxy_flag_routes_unprefixed_model(monkeypatch):
|
|
monkeypatch.setattr(runtime.litellm, "use_litellm_proxy", True)
|
|
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gw.example.com")
|
|
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
|
|
model, gateway = runtime.resolve_model_route("coder", None, None)
|
|
assert model == "coder" and gateway is not None
|
|
|
|
|
|
async def test_gateway_passed_to_endpoint(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch)
|
|
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gw.example.com")
|
|
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
|
|
await runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, model="litellm_proxy/m"
|
|
)
|
|
endpoint = FakeEndpoint.instances[0]
|
|
assert endpoint.gateway.api_key == "sk-test"
|
|
assert endpoint.model == "m"
|
|
assert endpoint.entered and endpoint.exited
|
|
|
|
|
|
# -- event flow ---------------------------------------------------------------
|
|
|
|
|
|
async def test_text_and_tool_events_flow_and_done_last(monkeypatch, sandbox):
|
|
adapter_cls = install_adapter(monkeypatch)
|
|
events = await _collect(
|
|
runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True)
|
|
)
|
|
kinds = [type(e).__name__ for e in events]
|
|
assert kinds == ["Text", "ToolCall", "ToolResult", "Text", "Done"]
|
|
assert sum(isinstance(e, Done) for e in events) == 1
|
|
result = events[-1].result
|
|
assert result.text == "hello world"
|
|
assert result.stop_reason == "done"
|
|
assert result.usage.input_tokens == 10 and result.usage.output_tokens == 5
|
|
assert result.cost == pytest.approx(0.25)
|
|
assert adapter_cls.instances[0].calls == ["start", "turn", "stop"]
|
|
|
|
|
|
async def test_arun_returns_result(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch)
|
|
result = await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
|
assert result.text == "hello world"
|
|
assert len(result.events) == 4
|
|
|
|
|
|
async def test_final_text_from_ctx_preferred(monkeypatch, sandbox):
|
|
async def script(adapter, ctx: SessionContext, prompt) -> AsyncIterator[Event]:
|
|
yield Text("partial")
|
|
ctx.final_text = "final answer"
|
|
|
|
install_adapter(monkeypatch, script=script)
|
|
result = await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
|
assert result.text == "final answer"
|
|
|
|
|
|
async def test_endpointless_adapter_usage(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch, uses_endpoint=False)
|
|
result = await runtime.aagent(Harness.DEEPAGENTS, "hi", sandbox=sandbox)
|
|
assert FakeEndpoint.instances == []
|
|
assert result.usage.calls == 1
|
|
assert result.cost == pytest.approx(0.25)
|
|
|
|
|
|
async def test_stream_result_property(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch)
|
|
stream = runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True)
|
|
assert stream.result is None
|
|
await _collect(stream)
|
|
assert stream.result is not None and stream.result.text == "hello world"
|
|
|
|
|
|
# -- stop reasons -------------------------------------------------------------
|
|
|
|
|
|
async def _tool_loop(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
|
for i in range(10):
|
|
yield ToolCall(id=str(i), name="bash", native_name="Bash", input={})
|
|
|
|
|
|
async def test_max_turns_stop(monkeypatch, sandbox):
|
|
adapter_cls = install_adapter(monkeypatch, script=_tool_loop)
|
|
result = await runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, max_turns=3
|
|
)
|
|
assert result.stop_reason == "max_turns"
|
|
assert sum(isinstance(e, ToolCall) for e in result.events) == 3
|
|
assert "stop" in adapter_cls.instances[0].calls
|
|
|
|
|
|
async def _slow(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
|
yield Text("thinking")
|
|
await wait_forever()
|
|
yield Text("never")
|
|
|
|
|
|
async def test_timeout_stop(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch, script=_slow)
|
|
result = await runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, timeout=0.2
|
|
)
|
|
assert result.stop_reason == "timeout"
|
|
assert result.text == "thinking"
|
|
|
|
|
|
async def test_cancel_stop(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch, script=_slow)
|
|
stream = runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True)
|
|
events = []
|
|
async for event in stream:
|
|
events.append(event)
|
|
if isinstance(event, Text):
|
|
stream.cancel()
|
|
assert isinstance(events[-1], Done)
|
|
assert events[-1].stop_reason == "cancelled"
|
|
|
|
|
|
async def _crash(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
|
yield Text("partial")
|
|
raise RuntimeError("process exited with code 1")
|
|
|
|
|
|
async def test_runtime_error_stop_reason(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch, script=_crash)
|
|
result = await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
|
assert result.stop_reason == "runtime_error"
|
|
assert "process exited with code 1" in result.text
|
|
|
|
|
|
async def _missing_binary(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
|
raise HarnessInstallFailed("claude not found on PATH")
|
|
yield Text("unreachable") # pragma: no cover
|
|
|
|
|
|
async def test_install_failed_propagates(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch, script=_missing_binary)
|
|
with pytest.raises(HarnessInstallFailed, match="claude"):
|
|
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
|
assert FakeEndpoint.instances[0].exited
|
|
|
|
|
|
# -- approvals ----------------------------------------------------------------
|
|
|
|
|
|
async def test_approval_on_approval_allow(monkeypatch, sandbox):
|
|
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
|
result = await runtime.aagent(
|
|
Harness.CLAUDE_CODE,
|
|
"hi",
|
|
sandbox=sandbox,
|
|
permissions="ask",
|
|
on_approval=lambda approval: approval.tool == "bash",
|
|
)
|
|
assert adapter_cls.instances[0].approvals[0][0] is True
|
|
assert result.text == "allowed"
|
|
|
|
|
|
async def test_approval_async_handler_deny(monkeypatch, sandbox):
|
|
async def handler(approval: Approval) -> bool:
|
|
await asyncio.sleep(0)
|
|
return False
|
|
|
|
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
|
result = await runtime.aagent(
|
|
Harness.CLAUDE_CODE,
|
|
"hi",
|
|
sandbox=sandbox,
|
|
permissions="ask",
|
|
on_approval=handler,
|
|
)
|
|
assert adapter_cls.instances[0].approvals[0][0] is False
|
|
assert result.text == "denied"
|
|
|
|
|
|
async def test_approval_handler_raises_denies(monkeypatch, sandbox):
|
|
def handler(approval: Approval) -> bool:
|
|
raise RuntimeError("boom")
|
|
|
|
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
|
result = await runtime.aagent(
|
|
Harness.CLAUDE_CODE,
|
|
"hi",
|
|
sandbox=sandbox,
|
|
permissions="ask",
|
|
on_approval=handler,
|
|
)
|
|
allowed, reason = adapter_cls.instances[0].approvals[0]
|
|
assert allowed is False and "boom" in reason
|
|
assert result.stop_reason == "done"
|
|
|
|
|
|
async def test_stream_consumer_answers_approval(monkeypatch, sandbox):
|
|
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
|
stream = runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="ask", stream=True
|
|
)
|
|
async for event in stream:
|
|
if isinstance(event, Approval):
|
|
event.allow()
|
|
assert adapter_cls.instances[0].approvals[0][0] is True
|
|
|
|
|
|
async def test_unanswered_approval_denied(monkeypatch, sandbox):
|
|
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
|
events = await _collect(
|
|
runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="ask", stream=True
|
|
)
|
|
)
|
|
allowed, reason = adapter_cls.instances[0].approvals[0]
|
|
assert allowed is False and "not answered" in reason
|
|
assert isinstance(events[-1], Done)
|
|
|
|
|
|
async def test_approval_without_ask_denied_in_run(monkeypatch, sandbox):
|
|
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
|
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
|
assert adapter_cls.instances[0].approvals[0][0] is False
|
|
|
|
|
|
# -- structured output --------------------------------------------------------
|
|
|
|
|
|
def _answer_script(text: str, output_json: str | None = None):
|
|
async def script(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
|
yield Text(text)
|
|
ctx.output_json = output_json
|
|
|
|
return script
|
|
|
|
|
|
async def test_structured_output_from_text(monkeypatch, sandbox):
|
|
install_adapter(
|
|
monkeypatch, script=_answer_script('Sure. {"x": 1} then {"value": 42} done')
|
|
)
|
|
result = await runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, output=Answer
|
|
)
|
|
assert result.output == Answer(value=42)
|
|
|
|
|
|
async def test_structured_output_from_ctx_output_json(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch, script=_answer_script("ok", '{"value": 7}'))
|
|
result = await runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, output=Answer
|
|
)
|
|
assert result.output == Answer(value=7)
|
|
|
|
|
|
async def test_structured_output_invalid_carries_result(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch, script=_answer_script('{"value": "nope"}'))
|
|
with pytest.raises(OutputInvalid) as info:
|
|
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, output=Answer)
|
|
assert info.value.raw == '{"value": "nope"}'
|
|
assert info.value.result is not None
|
|
assert info.value.result.text == '{"value": "nope"}'
|
|
|
|
|
|
async def test_structured_output_missing_json(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch, script=_answer_script("no json here"))
|
|
with pytest.raises(OutputInvalid, match="no JSON"):
|
|
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, output=Answer)
|
|
|
|
|
|
async def test_stream_yields_done_before_output_invalid(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch, script=_answer_script("nothing"))
|
|
stream = runtime.aagent(
|
|
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, output=Answer, stream=True
|
|
)
|
|
seen: list[object] = []
|
|
with pytest.raises(OutputInvalid):
|
|
await _drain_into(stream, seen)
|
|
assert isinstance(seen[-1], Done)
|
|
|
|
|
|
async def _drain_into(stream, seen: list[object]) -> None:
|
|
async for event in stream:
|
|
seen.append(event)
|
|
|
|
|
|
def test_last_json_object():
|
|
assert runtime.last_json_object('a {"a": {"b": 1}} b {"c": 2}') == '{"c": 2}'
|
|
assert runtime.last_json_object("{broken") is None
|
|
|
|
|
|
# -- files --------------------------------------------------------------------
|
|
|
|
|
|
async def _edit_files(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
|
root = ctx.sandbox.workdir
|
|
with open(os.path.join(root, "new.txt"), "w") as fh:
|
|
fh.write("new\n")
|
|
with open(os.path.join(root, "keep.txt"), "w") as fh:
|
|
fh.write("changed\n")
|
|
os.remove(os.path.join(root, "gone.txt"))
|
|
yield FileChange(path="new.txt", kind="created", diff=None)
|
|
yield Text("edited")
|
|
|
|
|
|
async def test_file_changes_emitted_once(monkeypatch, sandbox, tmp_path):
|
|
(tmp_path / "keep.txt").write_text("original\n")
|
|
(tmp_path / "gone.txt").write_text("bye\n")
|
|
install_adapter(monkeypatch, script=_edit_files)
|
|
events = await _collect(
|
|
runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True)
|
|
)
|
|
file_events = [e for e in events if isinstance(e, FileChange)]
|
|
assert sorted((e.path, e.kind) for e in file_events) == [
|
|
("gone.txt", "deleted"),
|
|
("keep.txt", "modified"),
|
|
("new.txt", "created"),
|
|
]
|
|
result = events[-1].result
|
|
by_path = {f.path: f for f in result.files}
|
|
assert set(by_path) == {"gone.txt", "keep.txt", "new.txt"}
|
|
assert "+changed" in by_path["keep.txt"].diff
|
|
assert "-bye" in by_path["gone.txt"].diff
|
|
assert "+new" in by_path["new.txt"].diff
|
|
assert isinstance(events[-1], Done)
|
|
|
|
|
|
# -- sessions -----------------------------------------------------------------
|
|
|
|
|
|
async def test_session_multi_turn_cost(monkeypatch, sandbox):
|
|
adapter_cls = install_adapter(monkeypatch)
|
|
async with runtime.aagent_session(Harness.CLAUDE_CODE, sandbox=sandbox) as session:
|
|
first = await session.arun("one")
|
|
second = await session.arun("two")
|
|
assert first.cost == pytest.approx(0.25)
|
|
assert second.cost == pytest.approx(0.25)
|
|
assert second.usage.input_tokens == 10
|
|
assert session.cost == pytest.approx(0.5)
|
|
assert session.usage.calls == 2
|
|
assert await session.history() == [
|
|
{"role": "user", "content": "one"},
|
|
{"role": "user", "content": "two"},
|
|
]
|
|
adapter = adapter_cls.instances[0]
|
|
assert adapter.calls == ["start", "turn", "turn", "stop"]
|
|
assert len(FakeEndpoint.instances) == 1
|
|
with pytest.raises(SessionClosed):
|
|
await session.arun("three")
|
|
|
|
|
|
async def test_await_asession(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch)
|
|
session = await runtime.aagent_session(Harness.CLAUDE_CODE, sandbox=sandbox)
|
|
result = await session.arun("hi")
|
|
await session.aclose()
|
|
assert result.text == "hello world"
|
|
|
|
|
|
async def test_session_restarts_after_timeout(monkeypatch, sandbox):
|
|
calls = {"n": 0}
|
|
|
|
async def script(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
await wait_forever()
|
|
yield Text("ok")
|
|
|
|
adapter_cls = install_adapter(monkeypatch, script=script)
|
|
async with runtime.aagent_session(
|
|
Harness.CLAUDE_CODE, sandbox=sandbox, timeout=0.2
|
|
) as session:
|
|
assert (await session.arun("one")).stop_reason == "timeout"
|
|
assert (await session.arun("two")).text == "ok"
|
|
adapter = adapter_cls.instances[0]
|
|
assert adapter.calls[:4] == ["start", "turn", "stop", "start"]
|
|
assert adapter.resumed_with == "native-123"
|
|
|
|
|
|
async def test_detach_state_round_trip_resume(monkeypatch, sandbox):
|
|
adapter_cls = install_adapter(monkeypatch)
|
|
async with runtime.aagent_session(
|
|
Harness.CLAUDE_CODE, sandbox=sandbox, model="m1"
|
|
) as session:
|
|
await session.arun("one")
|
|
state = await session.adetach()
|
|
data = state.dumps()
|
|
assert b"sk-" not in data
|
|
restored = State.loads(data)
|
|
assert restored == state and restored.native_session_id == "native-123"
|
|
|
|
async with runtime.aagent_resume(data, sandbox=sandbox) as resumed:
|
|
await resumed.arun("two")
|
|
new_adapter = adapter_cls.instances[-1]
|
|
assert new_adapter.calls[:2] == ["start", "resume"]
|
|
assert new_adapter.resumed_with == "native-123"
|
|
assert resumed.config.model == "m1"
|
|
|
|
|
|
async def test_resume_requires_capability(monkeypatch, sandbox):
|
|
install_adapter(
|
|
monkeypatch,
|
|
caps=NARROW_CAPS.__class__(**{**NARROW_CAPS.__dict__, "resume": False}),
|
|
)
|
|
state = State(harness=Harness.CODEX, native_session_id="x", workdir="/tmp")
|
|
with pytest.raises(CapabilityUnsupported, match="resume"):
|
|
runtime.aagent_resume(state, sandbox=sandbox)
|
|
|
|
|
|
async def test_resume_state_without_native_id(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch)
|
|
state = State(harness=Harness.CODEX, native_session_id=None, workdir="/tmp")
|
|
with pytest.raises(StateIncompatible):
|
|
runtime.aagent_resume(state, sandbox=sandbox)
|
|
|
|
|
|
async def test_history_requires_capability(monkeypatch, sandbox):
|
|
install_adapter(monkeypatch, caps=NARROW_CAPS)
|
|
async with runtime.aagent_session(Harness.CLAUDE_CODE, sandbox=sandbox) as session:
|
|
with pytest.raises(CapabilityUnsupported):
|
|
await session.history()
|
|
|
|
|
|
def test_capabilities_uses_registry(monkeypatch):
|
|
install_adapter(monkeypatch, caps=NARROW_CAPS)
|
|
assert runtime.agent_capabilities(Harness.CODEX) is NARROW_CAPS
|
|
with pytest.raises(TypeError):
|
|
runtime.agent_capabilities("codex") # type: ignore[arg-type]
|
|
|
|
|
|
def test_fake_adapter_is_a_harness_adapter():
|
|
assert issubclass(FakeAdapter, runtime.BaseHarnessHandler)
|
|
|
|
|
|
async def test_turn_keeps_every_event_when_queue_overflows(monkeypatch, sandbox):
|
|
"""A turn that emits more events than the queue holds must not drop any of them."""
|
|
total = 40
|
|
monkeypatch.setattr(runtime, "HARNESS_EVENT_QUEUE_MAX_SIZE", 4)
|
|
|
|
async def burst(adapter, ctx, prompt):
|
|
for i in range(total):
|
|
yield Text(f"{i},")
|
|
|
|
install_adapter(monkeypatch, script=burst)
|
|
result = await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
|
texts = [e.delta for e in result.events if isinstance(e, Text)]
|
|
assert texts == [f"{i}," for i in range(total)]
|
|
assert result.stop_reason == "done"
|