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
158 lines
5.6 KiB
Python
158 lines
5.6 KiB
Python
"""Lifecycle stage spans — investigation pipeline emits stage_kind spans."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from core.agent_harness.session.persistence.jsonl_storage import JsonlSessionStorage
|
|
from platform.observability.trace.spans import (
|
|
NoopSessionTraceSink,
|
|
bind_session_trace,
|
|
set_session_trace_sink,
|
|
)
|
|
from surfaces.interactive_shell.session.trace_sink import JsonlSessionTraceSink
|
|
from tools.investigation.stages.gather_evidence import ConnectedInvestigationAgent
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_session_trace_sink() -> Any:
|
|
set_session_trace_sink(NoopSessionTraceSink())
|
|
yield
|
|
set_session_trace_sink(NoopSessionTraceSink())
|
|
|
|
|
|
class _QuietAgent(ConnectedInvestigationAgent):
|
|
def run( # type: ignore[override]
|
|
self,
|
|
state: dict[str, Any], # noqa: ARG002
|
|
on_event: Any | None = None, # noqa: ARG002
|
|
) -> dict[str, Any]:
|
|
return {"gather_evidence_ran": True}
|
|
|
|
|
|
def test_run_connected_investigation_emits_stage_spans(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Each pipeline stage is timed under ``span_kind=stage`` when tracing is on."""
|
|
from tools.investigation.lifecycle import run_connected_investigation
|
|
from tools.investigation.state_factory import make_initial_state
|
|
|
|
monkeypatch.setattr(
|
|
"core.agent_harness.session.persistence.jsonl_storage.session_path",
|
|
lambda session_id: tmp_path / f"{session_id}.jsonl",
|
|
)
|
|
storage = JsonlSessionStorage()
|
|
session_id = "sess-lifecycle-stages"
|
|
path = tmp_path / f"{session_id}.jsonl"
|
|
path.write_text(
|
|
json.dumps({"type": "session", "version": 2, "id": session_id}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
set_session_trace_sink(JsonlSessionTraceSink(storage=storage))
|
|
|
|
state = make_initial_state(raw_alert="alert text")
|
|
with (
|
|
bind_session_trace(session_id),
|
|
patch(
|
|
"tools.investigation.stages.resolve_integrations.resolve_integrations",
|
|
return_value={"resolved_integrations": {}},
|
|
),
|
|
patch(
|
|
"tools.investigation.stages.intake.extract_alert",
|
|
return_value={"is_noise": False},
|
|
),
|
|
patch("tools.investigation.stages.plan_evidence.plan_actions", return_value={}),
|
|
patch(
|
|
"tools.investigation.reporting.upstream_correlation.node.node_correlate_upstream",
|
|
return_value={},
|
|
),
|
|
patch("tools.investigation.reporting.deliver", return_value={}),
|
|
):
|
|
run_connected_investigation(state, agent_class=_QuietAgent)
|
|
|
|
spans = [
|
|
json.loads(line)
|
|
for line in path.read_text(encoding="utf-8").strip().splitlines()
|
|
if json.loads(line).get("type") == "trace_span"
|
|
]
|
|
stage_names = [rec["name"] for rec in spans if rec.get("span_kind") == "stage"]
|
|
assert stage_names == [
|
|
"resolve_integrations",
|
|
"intake",
|
|
"plan_evidence",
|
|
"gather_evidence",
|
|
"diagnose",
|
|
"deliver",
|
|
]
|
|
assert all(rec["status"] == "ok" for rec in spans if rec.get("span_kind") == "stage")
|
|
assert all("duration_ms" in rec for rec in spans if rec.get("span_kind") == "stage")
|
|
|
|
|
|
def test_run_connected_investigation_skips_later_stages_on_noise(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from tools.investigation.lifecycle import run_connected_investigation
|
|
from tools.investigation.state_factory import make_initial_state
|
|
|
|
monkeypatch.setattr(
|
|
"core.agent_harness.session.persistence.jsonl_storage.session_path",
|
|
lambda session_id: tmp_path / f"{session_id}.jsonl",
|
|
)
|
|
storage = JsonlSessionStorage()
|
|
session_id = "sess-lifecycle-noise"
|
|
path = tmp_path / f"{session_id}.jsonl"
|
|
path.write_text(
|
|
json.dumps({"type": "session", "version": 2, "id": session_id}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
set_session_trace_sink(JsonlSessionTraceSink(storage=storage))
|
|
|
|
state = make_initial_state(raw_alert="noise")
|
|
with (
|
|
bind_session_trace(session_id),
|
|
patch(
|
|
"tools.investigation.stages.resolve_integrations.resolve_integrations",
|
|
return_value={"resolved_integrations": {}},
|
|
),
|
|
patch(
|
|
"tools.investigation.stages.intake.extract_alert",
|
|
return_value={"is_noise": True},
|
|
),
|
|
):
|
|
run_connected_investigation(state, agent_class=_QuietAgent)
|
|
|
|
stage_names = [
|
|
json.loads(line)["name"]
|
|
for line in path.read_text(encoding="utf-8").strip().splitlines()
|
|
if json.loads(line).get("type") == "trace_span"
|
|
and json.loads(line).get("span_kind") == "stage"
|
|
]
|
|
assert stage_names == ["resolve_integrations", "intake"]
|
|
|
|
|
|
def test_run_connected_investigation_noop_sink_emits_nothing() -> None:
|
|
"""Headless / gateway default: pipeline stages must not require a sink."""
|
|
from platform.observability.trace.spans import get_session_trace_sink
|
|
from tools.investigation.lifecycle import run_connected_investigation
|
|
from tools.investigation.state_factory import make_initial_state
|
|
|
|
assert isinstance(get_session_trace_sink(), NoopSessionTraceSink)
|
|
state = make_initial_state(raw_alert="alert text")
|
|
with (
|
|
patch(
|
|
"tools.investigation.stages.resolve_integrations.resolve_integrations",
|
|
return_value={"resolved_integrations": {}},
|
|
),
|
|
patch(
|
|
"tools.investigation.stages.intake.extract_alert",
|
|
return_value={"is_noise": True},
|
|
),
|
|
):
|
|
out = run_connected_investigation(state, agent_class=_QuietAgent)
|
|
assert out.get("is_noise") is True
|