litellm/tests/unit/harness/test_runtime.py
ishaan-berri 0f6a06a6b9
feat: add litellm.agent() to run claude code, codex, opencode and deep agents through the ai gateway (#43885)
* 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
2026-10-01 22:27:49 +00:00

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"