#!/usr/bin/env python3 """Run real end-to-end OpenHarness scenarios against an Anthropic-compatible API.""" from __future__ import annotations import argparse import asyncio import os import sys import tempfile from dataclasses import dataclass from pathlib import Path from typing import Callable sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from openharness.api.client import AnthropicApiClient from openharness.config.paths import get_project_issue_file, get_project_pr_comments_file from openharness.config.settings import load_settings from openharness.engine import QueryEngine from openharness.engine.stream_events import ( AssistantTurnComplete, ToolExecutionCompleted, ToolExecutionStarted, ) from openharness.mcp.client import McpClientManager from openharness.mcp.config import load_mcp_server_configs from openharness.mcp.types import McpStdioServerConfig from openharness.memory import add_memory_entry from openharness.permissions import PermissionChecker, PermissionMode from openharness.plugins import load_plugins from openharness.prompts import build_runtime_system_prompt from openharness.tools import create_default_tool_registry ScenarioSetup = Callable[[Path, Path], dict[str, object] | None] ScenarioValidate = Callable[[Path, str, list[str], int, int], tuple[bool, str]] FIXTURE_SERVER = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "fake_mcp_server.py" @dataclass(frozen=True) class Scenario: """One real-model scenario.""" name: str prompt: str expected_final: str required_tools: tuple[str, ...] validate: ScenarioValidate setup: ScenarioSetup | None = None ask_user_answer: str | None = None def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--model", default=None, help="Model name override") parser.add_argument("--base-url", default=None, help="Anthropic-compatible base URL") parser.add_argument( "--scenario", choices=[ "file_io", "search_edit", "phase48", "task_flow", "skill_flow", "mcp_model", "mcp_resource", "context_flow", "agent_flow", "remote_agent_flow", "plugin_combo", "ask_user_flow", "task_update_flow", "notebook_flow", "lsp_flow", "cron_flow", "worktree_flow", "issue_pr_context_flow", "mcp_auth_flow", "all", ], default="all", help="Scenario to run", ) parser.add_argument( "--api-key-stdin", action="store_true", help="Read the API key from stdin instead of environment variables", ) return parser.parse_args() def _validate_file_io(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: path = cwd / "smoke.txt" contents = path.read_text(encoding="utf-8").strip() if path.exists() else "" if started < 2 or completed < 2: return False, "model did not complete both tool calls" if "write_file" not in tool_names or "read_file" not in tool_names: return False, f"unexpected tool sequence: {tool_names}" if contents != "OPENHARNESS_E2E_OK": return False, f"unexpected smoke.txt contents: {contents!r}" if "FINAL_OK" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, contents def _validate_search_edit(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: path = cwd / "src" / "demo.py" contents = path.read_text(encoding="utf-8").strip() if path.exists() else "" required = {"write_file", "glob", "grep", "edit_file", "read_file"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "gamma" not in contents or "beta" in contents: return False, f"unexpected src/demo.py contents: {contents!r}" if "FINAL_OK_SEARCH_EDIT" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, contents def _validate_phase48(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: path = cwd / "TODO.md" contents = path.read_text(encoding="utf-8").strip() if path.exists() else "" required = {"tool_search", "todo_write", "read_file"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "phase48 smoke item" not in contents: return False, f"unexpected TODO.md contents: {contents!r}" if "FINAL_OK_PHASE48" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, contents def _validate_task_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: required = {"task_create", "sleep", "task_output"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "FINAL_OK_TASK" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, final_text def _setup_skill_flow(_: Path, config_dir: Path) -> None: skills_dir = config_dir / "skills" skills_dir.mkdir(parents=True, exist_ok=True) (skills_dir / "pytest.md").write_text( "# Pytest\nPytest fixtures help share setup across tests.\n", encoding="utf-8", ) return None def _setup_mcp_model_flow(_: Path, __: Path) -> dict[str, object]: return { "fixture": McpStdioServerConfig( command=sys.executable, args=[str(FIXTURE_SERVER)], ) } def _setup_context_flow(cwd: Path, _: Path) -> None: (cwd / "CLAUDE.md").write_text( "# Project Rules\nWhen asked to create config-like files, use KEY=value lines and always set COLOR=orange.\n", encoding="utf-8", ) add_memory_entry(cwd, "Codename", "CODENAME=aurora") return None def _setup_plugin_combo_flow(cwd: Path, _: Path) -> None: plugin_dir = cwd / ".openharness" / "plugins" / "fixture-plugin" (plugin_dir / "skills").mkdir(parents=True, exist_ok=True) (plugin_dir / "plugin.json").write_text( '{"name":"fixture-plugin","version":"1.0.0","description":"Fixture project plugin"}\n', encoding="utf-8", ) (plugin_dir / "skills" / "fixture.md").write_text( "# FixtureSkill\nThis plugin skill says COMBO_SKILL_OK.\n", encoding="utf-8", ) (plugin_dir / "mcp.json").write_text( ( '{"mcpServers":{"fixture":{"type":"stdio","command":"%s","args":["%s"]}}}\n' % (sys.executable, str(FIXTURE_SERVER)) ), encoding="utf-8", ) return None def _setup_worktree_flow(cwd: Path, _: Path) -> None: import subprocess subprocess.run(["git", "init"], cwd=cwd, check=True, capture_output=True, text=True) subprocess.run( ["git", "config", "user.email", "openharness@example.com"], cwd=cwd, check=True, capture_output=True, text=True, ) subprocess.run( ["git", "config", "user.name", "OpenHarness Tests"], cwd=cwd, check=True, capture_output=True, text=True, ) (cwd / "demo.txt").write_text("WORKTREE_OK\n", encoding="utf-8") subprocess.run(["git", "add", "-A"], cwd=cwd, check=True, capture_output=True, text=True) subprocess.run(["git", "commit", "-m", "init"], cwd=cwd, check=True, capture_output=True, text=True) return None def _setup_issue_pr_context_flow(cwd: Path, _: Path) -> None: get_project_issue_file(cwd).write_text( "# Fix flaky tasks\n\nThe main problem is the task retry path.\n", encoding="utf-8", ) get_project_pr_comments_file(cwd).write_text( "# PR Comments\n- src/tasks/manager.py:120: reviewer wants simpler restart handling.\n", encoding="utf-8", ) return None def _validate_skill_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: del cwd, started, completed if "skill" not in tool_names: return False, f"expected skill tool usage, got {tool_names}" if "FINAL_OK_SKILL" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, final_text def _validate_mcp_model_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: del cwd, started, completed if "mcp__fixture__hello" not in tool_names: return False, f"expected mcp tool usage, got {tool_names}" if "FINAL_OK_MCP" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, final_text def _validate_mcp_resource_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: del cwd, started, completed required = {"list_mcp_resources", "read_mcp_resource"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "FINAL_OK_MCP_RESOURCE" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, final_text def _validate_context_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: path = cwd / "note.env" contents = path.read_text(encoding="utf-8").strip() if path.exists() else "" required = {"write_file", "read_file"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "COLOR=orange" not in contents or "CODENAME=aurora" not in contents: return False, f"unexpected note.env contents: {contents!r}" if "FINAL_OK_CONTEXT" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, contents def _validate_agent_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: del cwd, started, completed required = {"agent", "send_message", "sleep", "task_output"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "FINAL_OK_AGENT" not in final_text: return False, f"unexpected final text: {final_text!r}" if "AGENT_ECHO:agent ping" not in final_text: return False, f"final text missing echoed agent output: {final_text!r}" return True, final_text def _validate_remote_agent_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: del cwd, started, completed required = {"agent", "send_message", "sleep", "task_output"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "FINAL_OK_REMOTE_AGENT" not in final_text: return False, f"unexpected final text: {final_text!r}" if "AGENT_ECHO:remote ping" not in final_text: return False, f"final text missing remote echoed output: {final_text!r}" return True, final_text def _validate_plugin_combo_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: del cwd, started, completed if "skill" not in tool_names: return False, f"expected plugin skill usage, got {tool_names}" if "mcp__fixture-plugin_fixture__hello" not in tool_names: return False, f"expected plugin mcp usage, got {tool_names}" if "FINAL_OK_PLUGIN_COMBO" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, final_text def _validate_ask_user_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: path = cwd / "answer.txt" contents = path.read_text(encoding="utf-8").strip() if path.exists() else "" required = {"ask_user_question", "write_file", "read_file"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if contents != "green": return False, f"unexpected answer.txt contents: {contents!r}" if "FINAL_OK_ASK_USER" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, contents def _validate_task_update_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: del cwd, started, completed required = {"task_create", "task_update", "task_get"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "FINAL_OK_TASK_UPDATE" not in final_text: return False, f"unexpected final text: {final_text!r}" if "75" not in final_text or "waiting on review" not in final_text: return False, f"final text missing updated task state: {final_text!r}" return True, final_text def _validate_notebook_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: path = cwd / "analysis.ipynb" contents = path.read_text(encoding="utf-8") if path.exists() else "" required = {"notebook_edit", "read_file"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "NB_OK" not in contents: return False, f"unexpected notebook contents: {contents!r}" if "FINAL_OK_NOTEBOOK" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, "NB_OK" def _setup_lsp_flow(cwd: Path, _: Path) -> None: (cwd / "pkg").mkdir(parents=True, exist_ok=True) (cwd / "pkg" / "utils.py").write_text( 'def greet(name):\n """Return a greeting."""\n return f"hi {name}"\n', encoding="utf-8", ) (cwd / "pkg" / "app.py").write_text( "from pkg.utils import greet\n\nprint(greet('world'))\n", encoding="utf-8", ) return None def _validate_lsp_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: del cwd, started, completed required = {"lsp"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "FINAL_OK_LSP" not in final_text: return False, f"unexpected final text: {final_text!r}" if "pkg/utils.py" not in final_text or "Return a greeting" not in final_text: return False, f"final text missing definition or docstring details: {final_text!r}" return True, final_text def _validate_cron_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: del cwd, started, completed required = {"cron_create", "cron_list", "remote_trigger", "cron_delete"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "FINAL_OK_CRON" not in final_text: return False, f"unexpected final text: {final_text!r}" if "CRON_SMOKE_OK" not in final_text: return False, f"missing cron output in final text: {final_text!r}" return True, final_text def _validate_worktree_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: worktree_path = cwd / ".openharness" / "worktrees" / "smoke-worktree" required = {"enter_worktree", "read_file", "exit_worktree"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if worktree_path.exists(): return False, f"worktree still exists: {worktree_path}" if "FINAL_OK_WORKTREE" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, final_text def _validate_issue_pr_context_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: path = cwd / "review_summary.md" contents = path.read_text(encoding="utf-8").strip() if path.exists() else "" required = {"write_file", "read_file"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "Fix flaky tasks" not in contents or "simpler restart handling" not in contents: return False, f"unexpected review_summary.md contents: {contents!r}" if "FINAL_OK_CONTEXT_REVIEW" not in final_text: return False, f"unexpected final text: {final_text!r}" return True, contents def _validate_mcp_auth_flow(cwd: Path, final_text: str, tool_names: list[str], started: int, completed: int) -> tuple[bool, str]: del cwd, started, completed required = {"mcp_auth", "mcp__fixture__hello"} if not required.issubset(set(tool_names)): return False, f"missing required tools: {sorted(required - set(tool_names))}" if "FINAL_OK_MCP_AUTH" not in final_text: return False, f"unexpected final text: {final_text!r}" if "fixture-hello:auth" not in final_text: return False, f"tool output missing after auth update: {final_text!r}" return True, final_text SCENARIOS: dict[str, Scenario] = { "file_io": Scenario( name="file_io", prompt=( "You are running an OpenHarness smoke test. " "You must use tools. " "1. Use write_file to create smoke.txt with the exact content OPENHARNESS_E2E_OK. " "2. Use read_file to verify the file content. " "3. Reply with exactly FINAL_OK once verification succeeds." ), expected_final="FINAL_OK", required_tools=("write_file", "read_file"), validate=_validate_file_io, ), "search_edit": Scenario( name="search_edit", prompt=( "You are running an OpenHarness search and edit test. " "You must use tools. " "1. Use write_file to create src/demo.py with two lines: alpha and beta. " "2. Use glob to find the python file. " "3. Use grep to confirm beta exists. " "4. Use edit_file to replace beta with gamma. " "5. Use read_file to confirm gamma is present. " "6. Reply with exactly FINAL_OK_SEARCH_EDIT." ), expected_final="FINAL_OK_SEARCH_EDIT", required_tools=("write_file", "glob", "grep", "edit_file", "read_file"), validate=_validate_search_edit, ), "phase48": Scenario( name="phase48", prompt=( "You are running an OpenHarness Phase 4-8 smoke test. " "You must use tools. " "1. Use tool_search to find the todo tool. " "2. Use todo_write to append a TODO item with the exact text phase48 smoke item. " "3. Use read_file to verify TODO.md contains that exact text. " "4. Reply with exactly FINAL_OK_PHASE48 once verification succeeds." ), expected_final="FINAL_OK_PHASE48", required_tools=("tool_search", "todo_write", "read_file"), validate=_validate_phase48, ), "task_flow": Scenario( name="task_flow", prompt=( "You are running an OpenHarness background task test. " "You must use tools. " "1. Use task_create with type local_bash and command printf 'TASK_FLOW_OK'. " "2. Use sleep for 0.2 seconds. " "3. Use task_output to read the created task output and verify it contains TASK_FLOW_OK. " "4. Reply with exactly FINAL_OK_TASK." ), expected_final="FINAL_OK_TASK", required_tools=("task_create", "sleep", "task_output"), validate=_validate_task_flow, ), "skill_flow": Scenario( name="skill_flow", prompt=( "You are running an OpenHarness skill loading test. " "You must use tools. " "1. Use the skill tool to read the Pytest skill. " "2. Verify it mentions fixtures. " "3. Reply with exactly FINAL_OK_SKILL." ), expected_final="FINAL_OK_SKILL", required_tools=("skill",), validate=_validate_skill_flow, setup=_setup_skill_flow, ), "mcp_model": Scenario( name="mcp_model", prompt=( "You are running an OpenHarness MCP integration test. " "You must use tools. " "1. Use mcp__fixture__hello with the argument name='kimi'. " "2. Verify the tool result contains fixture-hello:kimi. " "3. Reply with exactly FINAL_OK_MCP." ), expected_final="FINAL_OK_MCP", required_tools=("mcp__fixture__hello",), validate=_validate_mcp_model_flow, setup=_setup_mcp_model_flow, ), "mcp_resource": Scenario( name="mcp_resource", prompt=( "You are running an OpenHarness MCP resource test. " "You must use tools. " "1. Use list_mcp_resources. " "2. Use read_mcp_resource to read fixture://readme from server fixture. " "3. Verify the contents mention fixture resource contents. " "4. Reply with exactly FINAL_OK_MCP_RESOURCE." ), expected_final="FINAL_OK_MCP_RESOURCE", required_tools=("list_mcp_resources", "read_mcp_resource"), validate=_validate_mcp_resource_flow, setup=_setup_mcp_model_flow, ), "context_flow": Scenario( name="context_flow", prompt=( "Use the project instructions and persistent memory. " "Create note.env with exactly two lines: COLOR=orange and CODENAME=aurora. " "Use tools and verify the file by reading it. " "Reply with exactly FINAL_OK_CONTEXT." ), expected_final="FINAL_OK_CONTEXT", required_tools=("write_file", "read_file"), validate=_validate_context_flow, setup=_setup_context_flow, ), "agent_flow": Scenario( name="agent_flow", prompt=( "You are running an OpenHarness agent delegation test. " "You must use tools. " "1. Use the agent tool with description 'echo agent' and prompt 'ready'. " "Set command to exactly: while read line; do echo AGENT_ECHO:$line; break; done " "2. Use sleep for 0.2 seconds so the first agent run can finish. " "3. Use send_message to send exactly agent ping to the spawned task. " "4. Use sleep for 0.2 seconds. " "5. Use task_output to read the task output. " "6. Reply with exactly FINAL_OK_AGENT and include the observed AGENT_ECHO line." ), expected_final="FINAL_OK_AGENT", required_tools=("agent", "send_message", "sleep", "task_output"), validate=_validate_agent_flow, ), "remote_agent_flow": Scenario( name="remote_agent_flow", prompt=( "You are running an OpenHarness remote-agent mode test. " "You must use tools. " "1. Use the agent tool with mode remote_agent, description 'remote echo agent', and prompt 'ready'. " "Set command to exactly: while read line; do echo AGENT_ECHO:$line; break; done " "2. Use sleep for 0.2 seconds so the first agent run can finish. " "3. Use send_message to send exactly remote ping to the spawned task. " "4. Use sleep for 0.2 seconds. " "5. Use task_output to read the task output. " "6. Reply with exactly FINAL_OK_REMOTE_AGENT and include the observed AGENT_ECHO line." ), expected_final="FINAL_OK_REMOTE_AGENT", required_tools=("agent", "send_message", "sleep", "task_output"), validate=_validate_remote_agent_flow, ), "plugin_combo": Scenario( name="plugin_combo", prompt=( "You are running an OpenHarness plugin combo test. " "You must use tools. " "1. Use the skill tool to read FixtureSkill and verify it says COMBO_SKILL_OK. " "2. Use mcp__fixture-plugin_fixture__hello with name='combo'. " "3. Reply with exactly FINAL_OK_PLUGIN_COMBO." ), expected_final="FINAL_OK_PLUGIN_COMBO", required_tools=("skill", "mcp__fixture-plugin_fixture__hello"), validate=_validate_plugin_combo_flow, setup=_setup_plugin_combo_flow, ), "ask_user_flow": Scenario( name="ask_user_flow", prompt=( "You are running an OpenHarness ask-user test. " "You must use tools. " "1. Use ask_user_question to ask exactly What color should answer.txt contain? " "2. Use write_file to create answer.txt with the returned answer and nothing else. " "3. Use read_file to verify the file content. " "4. Reply with exactly FINAL_OK_ASK_USER." ), expected_final="FINAL_OK_ASK_USER", required_tools=("ask_user_question", "write_file", "read_file"), validate=_validate_ask_user_flow, ask_user_answer="green", ), "task_update_flow": Scenario( name="task_update_flow", prompt=( "You are running an OpenHarness task update test. " "You must use tools. " "1. Use task_create with type local_bash and command printf 'TASK_UPDATE_OK'. " "2. Use task_update to set progress to 75 and status_note to waiting on review. " "3. Use task_get to inspect the task and verify both updates are present. " "4. Reply with exactly FINAL_OK_TASK_UPDATE and include 75 and waiting on review." ), expected_final="FINAL_OK_TASK_UPDATE", required_tools=("task_create", "task_update", "task_get"), validate=_validate_task_update_flow, ), "notebook_flow": Scenario( name="notebook_flow", prompt=( "You are running an OpenHarness notebook test. " "You must use tools. " "1. Use notebook_edit to create analysis.ipynb and set cell 0 to exactly print('NB_OK'). " "2. Use read_file to verify the notebook JSON contains NB_OK. " "3. Reply with exactly FINAL_OK_NOTEBOOK." ), expected_final="FINAL_OK_NOTEBOOK", required_tools=("notebook_edit", "read_file"), validate=_validate_notebook_flow, ), "lsp_flow": Scenario( name="lsp_flow", prompt=( "You are running an OpenHarness LSP test. " "You must use tools. " "1. Use lsp on pkg/app.py to find the definition of greet. " "2. Use lsp hover on greet to confirm the docstring says Return a greeting. " "3. Reply with exactly FINAL_OK_LSP and include the definition path and the docstring text." ), expected_final="FINAL_OK_LSP", required_tools=("lsp",), validate=_validate_lsp_flow, setup=_setup_lsp_flow, ), "cron_flow": Scenario( name="cron_flow", prompt=( "You are running an OpenHarness cron test. " "You must use tools. " "1. Use cron_create with name smoke-cron, schedule daily, and command printf 'CRON_SMOKE_OK'. " "2. Use cron_list to verify smoke-cron exists. " "3. Use remote_trigger with name smoke-cron and verify the output contains CRON_SMOKE_OK. " "4. Use cron_delete to remove smoke-cron. " "5. Reply with exactly FINAL_OK_CRON and include CRON_SMOKE_OK." ), expected_final="FINAL_OK_CRON", required_tools=("cron_create", "cron_list", "remote_trigger", "cron_delete"), validate=_validate_cron_flow, ), "worktree_flow": Scenario( name="worktree_flow", prompt=( "You are running an OpenHarness worktree test. " "You must use tools. " "1. Use enter_worktree with branch smoke/worktree. " "2. Use read_file to read demo.txt inside the returned worktree path and verify it contains WORKTREE_OK. " "3. Use exit_worktree to remove that worktree path. " "4. Reply with exactly FINAL_OK_WORKTREE." ), expected_final="FINAL_OK_WORKTREE", required_tools=("enter_worktree", "read_file", "exit_worktree"), validate=_validate_worktree_flow, setup=_setup_worktree_flow, ), "issue_pr_context_flow": Scenario( name="issue_pr_context_flow", prompt=( "Use the project issue and PR comment context. " "Create review_summary.md with one line containing the issue title and one line containing the reviewer request. " "Use tools to write and verify the file. " "Reply with exactly FINAL_OK_CONTEXT_REVIEW." ), expected_final="FINAL_OK_CONTEXT_REVIEW", required_tools=("write_file", "read_file"), validate=_validate_issue_pr_context_flow, setup=_setup_issue_pr_context_flow, ), "mcp_auth_flow": Scenario( name="mcp_auth_flow", prompt=( "You are running an OpenHarness MCP auth reconfiguration test. " "You must use tools. " "1. Use mcp_auth on server fixture with mode bearer and value token-smoke. " "2. Use mcp__fixture__hello with name='auth'. " "3. Verify the output contains fixture-hello:auth. " "4. Reply with exactly FINAL_OK_MCP_AUTH and include fixture-hello:auth." ), expected_final="FINAL_OK_MCP_AUTH", required_tools=("mcp_auth", "mcp__fixture__hello"), validate=_validate_mcp_auth_flow, setup=_setup_mcp_model_flow, ), } async def _run_scenario( *, scenario: Scenario, suite_root: Path, client: AnthropicApiClient, model: str, ) -> tuple[bool, str]: cwd = suite_root / scenario.name cwd.mkdir(parents=True, exist_ok=True) config_dir = suite_root / "config" config_dir.mkdir(parents=True, exist_ok=True) server_configs = scenario.setup(cwd, config_dir) if scenario.setup is not None else None settings = load_settings().merge_cli_overrides(model=model) plugins = load_plugins(settings, cwd) permission_settings = settings.permission.model_copy(update={"mode": PermissionMode.FULL_AUTO}) merged_server_configs = load_mcp_server_configs(settings, plugins) if server_configs: merged_server_configs.update(server_configs) mcp_manager = McpClientManager(merged_server_configs) if merged_server_configs else None if mcp_manager is not None: await mcp_manager.connect_all() try: engine = QueryEngine( api_client=client, tool_registry=create_default_tool_registry(mcp_manager), permission_checker=PermissionChecker(permission_settings), cwd=cwd, model=settings.model, system_prompt=build_runtime_system_prompt(settings, cwd=cwd), max_tokens=min(settings.max_tokens, 4096), tool_metadata={"mcp_manager": mcp_manager} if mcp_manager is not None else None, ask_user_prompt=( None if scenario.ask_user_answer is None else (lambda _question: asyncio.sleep(0, result=scenario.ask_user_answer)) ), ) tool_names: list[str] = [] started = 0 completed = 0 final_text = "" async for event in engine.submit_message(scenario.prompt): if isinstance(event, ToolExecutionStarted): started += 1 tool_names.append(event.tool_name) print(f"[{scenario.name}] tool-start {event.tool_name}") elif isinstance(event, ToolExecutionCompleted): completed += 1 print(f"[{scenario.name}] tool-done {event.tool_name} error={event.is_error}") elif isinstance(event, AssistantTurnComplete): final_text = event.message.text.strip() ok, detail = scenario.validate(cwd, final_text, tool_names, started, completed) status = "PASS" if ok else "FAIL" print(f"[{scenario.name}] final_text={final_text}") print(f"[{scenario.name}] tools={tool_names}") print(f"[{scenario.name}] result={status} detail={detail}") return ok, detail finally: if mcp_manager is not None: await mcp_manager.close() async def _run() -> int: args = _parse_args() api_key = sys.stdin.readline().strip() if args.api_key_stdin else load_settings().resolve_api_key() if not api_key: raise SystemExit("Missing API key.") selected = list(SCENARIOS) if args.scenario == "all" else [args.scenario] with tempfile.TemporaryDirectory(prefix="openharness-e2e-suite-") as temp_dir: suite_root = Path(temp_dir) previous_env = { "OPENHARNESS_CONFIG_DIR": os.environ.get("OPENHARNESS_CONFIG_DIR"), "OPENHARNESS_DATA_DIR": os.environ.get("OPENHARNESS_DATA_DIR"), } os.environ["OPENHARNESS_CONFIG_DIR"] = str(suite_root / "config") os.environ["OPENHARNESS_DATA_DIR"] = str(suite_root / "data") try: settings = load_settings().merge_cli_overrides(model=args.model, base_url=args.base_url) client = AnthropicApiClient(api_key=api_key, base_url=settings.base_url) failures: list[str] = [] for name in selected: ok, detail = await _run_scenario( scenario=SCENARIOS[name], suite_root=suite_root, client=client, model=settings.model, ) if not ok: failures.append(f"{name}: {detail}") if failures: print("Suite failed:", file=sys.stderr) for failure in failures: print(f"- {failure}", file=sys.stderr) return 1 print(f"Suite passed for scenarios: {', '.join(selected)}") return 0 finally: for key, value in previous_env.items(): if value is None: os.environ.pop(key, None) else: os.environ[key] = value def main() -> int: return asyncio.run(_run()) if __name__ == "__main__": raise SystemExit(main())