4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
175 lines
7.3 KiB
Python
175 lines
7.3 KiB
Python
"""Drive an OpenClaw conversation against a handle, capture the failure.
|
|
|
|
Pure business logic: no pytest, no fixtures, no fault injection. Given
|
|
an :class:`OpenClawHandle`, builds an :class:`OpenClawConfig` for the
|
|
stdio MCP bridge, attempts to call an OpenClaw MCP tool, and returns a
|
|
context dict shaped for :func:`orchestrator.run_openclaw_investigation`.
|
|
|
|
Used by every fault scenario test — gateway-down, tool-call-timeout,
|
|
and wrong-endpoint all share the same "call a tool, capture what fires"
|
|
pattern. The fault is set up beforehand by ``fault_injection.inject_*``;
|
|
this function just observes the resulting failure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from integrations.openclaw import (
|
|
OpenClawConfig,
|
|
call_openclaw_tool,
|
|
describe_openclaw_error,
|
|
validate_openclaw_config,
|
|
)
|
|
from tests.e2e.openclaw.infrastructure_sdk.local import OpenClawHandle
|
|
|
|
# Tool we call to exercise the bridge. ``conversations_list`` is the
|
|
# OpenClaw MCP tool name (``search_openclaw_conversations`` is the
|
|
# OpenSRE-side wrapper name in
|
|
# :mod:`integrations.openclaw.tools.openclaw_mcp_tool`). We hit it raw here so the test
|
|
# exercises the same code path as a real investigation, without going
|
|
# through the agent's tool selection.
|
|
_PROBE_TOOL = "conversations_list"
|
|
|
|
# Fault injectors stash an :class:`OpenClawConfig` override here when a
|
|
# scenario needs to point the use_case driver at something other than
|
|
# the default stdio bridge (e.g. ``inject_wrong_endpoint`` overrides
|
|
# the transport and URL). The use_case reads the override and falls
|
|
# back to ``_build_stdio_config`` when absent.
|
|
HANDLE_CONFIG_KEY = "openclaw_config"
|
|
|
|
|
|
def _build_stdio_config() -> OpenClawConfig:
|
|
"""Build the canonical stdio-transport config that drives the
|
|
bridge subprocess.
|
|
|
|
The bridge inherits the parent's PATH and config dir, so a
|
|
contributor running this test with their normal ``~/.openclaw/``
|
|
config gets a bridge that tries to talk to whatever Gateway URL
|
|
that config names. For the gateway-down scenario we just need the
|
|
bridge to fail; it doesn't matter which Gateway address it tries.
|
|
"""
|
|
return OpenClawConfig(
|
|
mode="stdio",
|
|
command="openclaw",
|
|
args=("mcp", "serve"),
|
|
# Short timeout so a tool-call-timeout test can fail fast in
|
|
# the follow-up scenario (#5). Gateway-down typically fails
|
|
# within 1-2s on its own (Connection closed) so this is well
|
|
# above what the failure path needs.
|
|
timeout_seconds=10.0,
|
|
integration_id="openclaw-e2e",
|
|
)
|
|
|
|
|
|
def _resolve_config(handle: OpenClawHandle) -> OpenClawConfig:
|
|
"""Return the per-scenario config override if a fault injector
|
|
stashed one, otherwise the canonical stdio bridge config.
|
|
|
|
The override pattern keeps the use_case interface uniform — every
|
|
fault scenario calls ``drive_openclaw_conversation(handle)`` and
|
|
the per-fault wiring lives on the handle.
|
|
"""
|
|
override = handle.extra.get(HANDLE_CONFIG_KEY)
|
|
if isinstance(override, OpenClawConfig):
|
|
return override
|
|
return _build_stdio_config()
|
|
|
|
|
|
def drive_openclaw_conversation(handle: OpenClawHandle) -> dict[str, Any]:
|
|
"""Run a single OpenClaw MCP tool call against ``handle``, return
|
|
the captured failure context.
|
|
|
|
Returns a dict shaped for :func:`orchestrator.run_openclaw_investigation`:
|
|
|
|
{
|
|
"tool": "conversations_list",
|
|
"transport_mode": "stdio",
|
|
"command": "openclaw",
|
|
"args": "mcp serve",
|
|
"gateway_url": "ws://127.0.0.1:19001" | None,
|
|
"last_error": "<one-line summary>",
|
|
"error_detail": "<full describe_openclaw_error output>",
|
|
"failure_mode": "gateway_down" | "tool_call_timeout" | "wrong_endpoint" | "unknown",
|
|
}
|
|
|
|
On the happy path (no fault injected, Gateway healthy) the dict has
|
|
``"failure_mode": "no_failure"`` and the assertions in the scenario
|
|
test should fail explicitly so the test author sees the missing
|
|
fault setup rather than a passing "RCA correctly identified
|
|
nothing" run.
|
|
"""
|
|
config = _resolve_config(handle)
|
|
failure_mode = _infer_failure_mode(handle)
|
|
base_context: dict[str, Any] = {
|
|
"tool": _PROBE_TOOL,
|
|
"transport_mode": config.mode,
|
|
"command": config.command,
|
|
"args": " ".join(config.args),
|
|
"url": config.url,
|
|
"gateway_url": handle.gateway_url,
|
|
"failure_mode": failure_mode,
|
|
}
|
|
|
|
# Pre-flight config validation. ``validate_openclaw_config`` catches
|
|
# the Control-UI-vs-MCP-bridge misconfiguration (and similar) before
|
|
# we attempt a tool call, surfacing the same hint a user would see
|
|
# from ``opensre integrations verify``. For stdio configs this is a
|
|
# pass-through.
|
|
validation = validate_openclaw_config(config)
|
|
if not validation.ok:
|
|
base_context["last_error"] = (validation.detail or "config invalid")[:200]
|
|
base_context["error_detail"] = validation.detail or "config invalid"
|
|
return base_context
|
|
|
|
try:
|
|
result = call_openclaw_tool(config, _PROBE_TOOL, {})
|
|
except Exception as err: # noqa: BLE001 — capture operational failures; KeyboardInterrupt/SystemExit propagate
|
|
base_context["last_error"] = (
|
|
str(err).splitlines()[0][:200] if str(err) else type(err).__name__
|
|
)
|
|
base_context["error_detail"] = describe_openclaw_error(err, config)
|
|
return base_context
|
|
|
|
# No exception — either the Gateway was actually up (test setup bug)
|
|
# or the MCP tool returned an ``is_error`` payload. The bridge maps
|
|
# most gateway failures to ``is_error=True`` rather than letting them
|
|
# raise, so we have to re-run them through ``describe_openclaw_error``
|
|
# ourselves to get the canonical hint surface (otherwise the
|
|
# orchestrator's alert would carry just the raw "ECONNREFUSED 127.0.0.1"
|
|
# text without the "start `openclaw gateway run`" remediation).
|
|
if result.get("is_error"):
|
|
error_text = str(result.get("text", "") or "")
|
|
# ``describe_openclaw_error`` wants an exception; synthesize one
|
|
# carrying the same message so the indicator-matching logic in
|
|
# ``_looks_like_openclaw_gateway_unavailable`` still fires.
|
|
synthetic_error = RuntimeError(error_text)
|
|
base_context["last_error"] = error_text.splitlines()[0][:200]
|
|
base_context["error_detail"] = describe_openclaw_error(synthetic_error, config)
|
|
return base_context
|
|
|
|
base_context["failure_mode"] = "no_failure"
|
|
base_context["last_error"] = ""
|
|
base_context["error_detail"] = (
|
|
"OpenClaw MCP tool call succeeded — no fault was active. "
|
|
"Did the fault injector run? Was the Gateway accidentally up?"
|
|
)
|
|
return base_context
|
|
|
|
|
|
def _infer_failure_mode(handle: OpenClawHandle) -> str:
|
|
"""Tag the failure mode based on handle state so the orchestrator
|
|
can build a more targeted alert annotation.
|
|
|
|
Fault injectors stash a ``"fault"`` key in ``handle.extra`` to be
|
|
explicit about which scenario set up the handle. Falls back to a
|
|
"Gateway absent ⇒ gateway_down" inference for backward-compat with
|
|
handles booted via ``boot_openclaw(with_gateway=False)`` directly.
|
|
"""
|
|
explicit = handle.extra.get("fault")
|
|
if isinstance(explicit, str) and explicit:
|
|
return explicit
|
|
if handle.gateway_pid is None:
|
|
return "gateway_down"
|
|
return "unknown"
|